目录
1.依赖介绍
之前写了两篇有关shiro和spring的文章,结合目前java开发spring boot框架是大势,所以有必要将shiro和spring boot进行一次整合。我在使用spring boot的时候比较明显的一个感触就是原来spring的xml配置后面都使用java bean的形式替代了。总体看java代码的程度更加纯粹了。首先是spring和shiro整合所需要的一些依赖
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring-boot-web-starter</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>org.crazycake</groupId>
<artifactId>shiro-redis</artifactId>
<version>3.3.1</version>
</dependency>
这两个一个是spring boot整合shiro的一个web依赖,另一个是提供shiro缓存的依赖(也是网上大家用的比较多的一个)。别的就是web开发中常用的依赖加上就可以了。这里需要注意一下版本,因为开发的时候我用的版本是比较高的,和之前用低版本开发会不一样遇到了一些坑。
2.组件介绍
shiro主要由这些组件构成,这些组件能够支撑一个最小权限系统。在外部我们引用redis来做shiro的缓存载体。
首先通过IDE创建一个spring boot工程,在pom.xml文件中加入所需要的依赖。然后创建一个shiro的包(便于管理把shiro相关的配置放在包中)。第一个配置创建一个ShiroConfiguration类,这个类就是总的shiro的配置管理,前面图中的组件在这个类中被配置。
总的配置组件结构图如下
在configuration类创建一个shiroFilterFactoryBean然后注入安全管理器SecurityManager,创建一个linkedHashMap(有序),里面配置路由的规则,规定哪条路由匹配到哪个过滤器中。如果是前后端不分离的项目,一般可以使用shiro提供的默认过滤器,比较常用的就是anon(匿名访问),auth(登录授权访问)下面开始是将一些静态资源做过滤,比如用swagger做接口文档的,就需要把swagger的几条路由匹配到anon,而需要保护的资源就匹配到auth过滤器上
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager){
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setLoginUrl("/login.html");
shiroFilterFactoryBean.setSecurityManager(securityManager);
LinkedHashMap<String, String> filterChainMap = new LinkedHashMap<String, String>();
filterChainMap.put("/swagger-ui/**","anon");
filterChainMap.put("/webjars/**","anon");
filterChainMap.put("/swagger-resources/**","anon");
filterChainMap.put("/v3/**","anon");
filterChainMap.put("/user/login","anon");
filterChainMap.put("/**","auth");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainMap);
return shiroFilterFactoryBean;
}
3.SecurityManager组件和Realm组件设计
3.1SecurityManager组件
配置完shiroFilterFactoryBean后,我们需要配置securityManager安全管理器,按照之前的结构图,里面会设置三个组件
1、用来实现登录和授权的realm;2、用来管理会话维持登录状态的sessionManager;3、缓存实现的管理器CacheManager.
@Bean
public DefaultWebSecurityManager securityManager(SessionManager sessionManager,RedisCacheManager shiroRedisCacheManager,
SelfRealm selfRealm){
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(selfRealm);
securityManager.setSessionManager(sessionManager);
securityManager.setCacheManager(shiroRedisCacheManager);
return securityManager;
}
3.2Realm设计
然后来依次配置这三个组件,首先是Realm,创建一个Java class然后继承AuthorizingRealm,重写父类中的doGetAuthenticationInfo和doGetAuthorizationInfo方法。这两个方法分别是实现对用户登录的验证和校验用户的权限的。
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
String principal = authenticationToken.getPrincipal().toString();
ShiroUserPO user = shiroUserDao.getUserByName(principal);
if (null == user){
throw new UnknownAccountException();
}
if (user.getLocked()){
throw new LockedAccountException();
}
SimpleAuthenticationInfo authenticationInfo = new SimpleAuthenticationInfo(user,user.getUserPassword(),getName());
return authenticationInfo;
}
这里有一个AuthenticationToken 参数,这个参数是个接口,我们接受的默认是它的UsernamePasswordToken实现。这个实现中可以获取用户登录时传递的账号和密码。通过账号我们可以查询出这个的账号信息,这个方法需要返回一个AuthenticationInfo类型的数据,这个也是一个接口,默认的实现是SimpleAuthenticationInfo。这里需要和我们定义的加密算法组件联系起来。在系统中我们选择加盐加密的方式来对密码进行编码保存。
@Bean
public SelfRealm selfRealm(){
SelfRealm selfRealm = new SelfRealm();
selfRealm.setCredentialsMatcher(passwordMatcher());
return selfRealm;
}
@Bean
public PasswordMatcher passwordMatcher(){
PasswordMatcher passwordMatcher = new PasswordMatcher();
return passwordMatcher;
}
将前面创建的realm交给spring容器管理。配置它的凭证匹配器,这个匹配器这里使用shiro包中提供的新的加密类。之前旧版本里面匹配器大多是这样实现的,实例化一个哈希凭证匹配器,定义算法和迭代次数。
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher(){
HashedCredentialsMatcher hashedCredentialsMatcher = new HashedCredentialsMatcher();
hashedCredentialsMatcher.setHashAlgorithmName(Md5Hash.ALGORITHM_NAME);
hashedCredentialsMatcher.setHashIterations(2);
return hashedCredentialsMatcher;
}
这里我们使用默认的passwordMathcer,它采用的是SHA-256算法,迭代500000,加盐加密。到这里登录验证的配置基本完成。源码上shiro大概是这样的实现流程
Subject currentUser = SecurityUtils.getSubject();
第一步通过上下文得到一个主体对象,用于保存用户信息和会话信息
private void shiroLogin(LoginParam loginParam, Subject currentUser){
UsernamePasswordToken token = new UsernamePasswordToken(loginParam.getUserName(),loginParam.getPassword());
try {
currentUser.login(token);
}catch (UnknownAccountException ua){
ErrorLogUtil.errorLog(ua);
throw new ServiceErrorException(ServiceErrorEnum.UN_KNOWN_ACCOUNT);
}catch (IncorrectCredentialsException ice){
ErrorLogUtil.errorLog(ice);
throw new ServiceErrorException(ServiceErrorEnum.ERROR_CREDENTIALS);
}catch (LockedAccountException le){
ErrorLogUtil.errorLog(le);
throw new ServiceErrorException(ServiceErrorEnum.LOCK_ACCOUNT);
}catch (AuthenticationException ae){
ErrorLogUtil.errorLog(ae);
throw new ServiceErrorException(ServiceErrorEnum.AUTH_ERROR);
}
}
第二步将前台传递的账号和密码封装到UernamePasswordToken对象中,并调用Subject的login方法。这个Suject由DelegatingSubject实现其login方法,我们截取片段,这里是又是调用这个安全管理器SecurityManager的login方法。而这个管理器组件在之前的配置类中我们已经配置过了,它由DefaultSecurityManager来实现login方法
public void login(AuthenticationToken token) throws AuthenticationException {
this.clearRunAsIdentitiesInternal();
Subject subject = this.securityManager.login(this, token);
String host = null;
PrincipalCollection principals;
if (subject instanceof DelegatingSubject) {
DelegatingSubject delegating = (DelegatingSubject)subject;
principals = delegating.principals;
host = delegating.host;
} else {
principals = subject.getPrincipals();
}
if (principals != null && !principals.isEmpty()) {
this.principals = principals;
this.authenticated = true;
if (token instanceof HostAuthenticationToken) {
host = ((HostAuthenticationToken)token).getHost();
}
在DefaultSecurityManager中声明了AuthenticationInfo用于保存用户的信息,这里的authenticate方法会调用AuthenticatingSecurityManager这个抽象类中的方法,
public Subject login(Subject subject, AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info;
try {
info = this.authenticate(token);
} catch (AuthenticationException var7) {
AuthenticationException ae = var7;
而AuthenticatingSecurityManager会调用Authenticator接口的实现类AbstractAuthenticator。这个实现类本身是个抽象类,所声明的这个doAuthenticate方法也是抽象方法。所以要继续看它的实现类ModularRealmAuthenticator
protected AuthenticationInfo doAuthenticate(AuthenticationToken authenticationToken) throws AuthenticationException {
this.assertRealmsConfigured();
Collection<Realm> realms = this.getRealms();
return realms.size() == 1 ? this.doSingleRealmAuthentication((Realm)realms.iterator().next(), authenticationToken) : this.doMultiRealmAuthentication(realms, authenticationToken);
}
可以看到这里会获取我们在之前securityManager中配置的所有realm。如果你配置了单个realm那就直接执行这个realm的登录校验。如果是多realm,那么会遍历这个realm组,把支持你传入的这个令牌token类型的realm都执行一遍它们的登录校验实现,然后将得到的AuthenticationInfo 合并起来,可以简单认为是将各个凭证放到集合中。这里先只考虑单个realm的实现
接着会调用到抽象类AuthenticatingRealm,之前我们自定义的realm就是继承它。doGetAuthenticationInfo就是获取账号信息的实现,而assertCredentialsMatch就是密码的校验,这里shiro会去读取配置类中我们设置凭证匹配器来选择对应的实现。到这里登录的认证就结束了
public final AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
AuthenticationInfo info = this.getCachedAuthenticationInfo(token);
if (info == null) {
info = this.doGetAuthenticationInfo(token);
log.debug("Looked up AuthenticationInfo [{}] from doGetAuthenticationInfo", info);
if (token != null && info != null) {
this.cacheAuthenticationInfoIfPossible(token, info);
}
} else {
log.debug("Using cached authentication info [{}] to perform credentials matching.", info);
}
if (info != null) {
this.assertCredentialsMatch(token, info);
} else {
log.debug("No AuthenticationInfo found for submitted AuthenticationToken [{}]. Returning null.", token);
}
return info;
}
3.3SessionManager组件
然后shiro是如何维持这个会话的呢?这里依赖于sessionManager这个组件,默认这个会话是保存在服务器的内存中的,但是我们可以通过修改sessionDao来更改它的存储方式。这里使用大家比较熟悉的redis来存储。首先在配置类中声明一个redis的底层容器,配置了redis的主机端口和密码。
@Bean
public RedisManager redisManager(@Value("${spring.redis.host}") String host,
@Value("${spring.redis.port}") String port,
@Value("${spring.redis.password}") String password){
RedisManager redisManager = new RedisManager();
redisManager.setHost(host+":"+port);
redisManager.setPassword(password);
return redisManager;
}
然后以这个redisManager为基础来配置两个redis的组件。一个是shiro的session存储实现redisSessionDao,另一个是shiro的缓存实现redisCacheManager。
@Bean("shiroRedisCacheManager")
public RedisCacheManager shiroRedisCacheManager(RedisManager redisManager){
RedisCacheManager redisCacheManager = new RedisCacheManager();
redisCacheManager.setRedisManager(redisManager);
//重写凭证的缓存key字段名
redisCacheManager.setPrincipalIdFieldName("userId");
return redisCacheManager;
}
@Bean
public RedisSessionDAO redisSessionDAO(RedisManager redisManager){
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager);
return redisSessionDAO;
}
然后将这两个组件配置到sessionManager中
@Bean
public SessionManager sessionManager(RedisSessionDAO redisSessionDAO,RedisCacheManager shiroRedisCacheManager){
SelfSessionManager sessionManager = new SelfSessionManager();
sessionManager.setSessionDAO(redisSessionDAO);
sessionManager.setCacheManager(shiroRedisCacheManager);
return sessionManager;
}
redisSessionDao默认的缓存设计是以shiro:session作为key的前缀,用sessionId作为key来保存session信息。默认的过期策略是将redisSession的过期时间设置和shiroSession的一致。开发者可以修改过期策略自定义redisSession的过期时间。当redisSession的过期时间小于shiroSession是程序会打印警告日志。
3.5权限注解激活
shiro除了登录校验和会话管理之外比较重要的就是权限的管理了。其中获取账号的权限就是由之前的realm来实现。一般我们会使用注解来检验权限。所以在配置类中要激活权限
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
在低版本的shiro中激活权限注解使用,,只要配置上面的这容器就可以了。但是在高版本的shiro中存在问题,只使用上面的配置,权限注解不能正常使用,同时使用swagger后所有加上权限注解的接口在文档中都无法显示。网上给出的结论是和spring的aop有冲突,需要增加下面配置
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setUsePrefix(true);
return defaultAdvisorAutoProxyCreator;
}
3.6权限管理
在realm中实现doGetAuthorizationInfo方法,主要的操作就是将账号对应存储的角色和权限查询出来,赋值到AuthorizationInfo上。用到角色注解的就查询并赋值角色信息,用到权限注解的就查询并赋值权限信息。
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
ShiroUserPO shiroUserPO = (ShiroUserPO) principalCollection.getPrimaryPrincipal();
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
List<RolePO> rolePOList = shiroUserDao.getUserRoleByName(shiroUserPO.getUserName());
Set<String> roleSet = rolePOList.stream().map(RolePO::getRoleName).collect(Collectors.toSet());
simpleAuthorizationInfo.setRoles(roleSet);
if (CollectionUtils.isEmpty(rolePOList)){
return simpleAuthorizationInfo;
}
List<PermissionPO> permissionPOList = roleDao.getPermissionByRoleIdS(rolePOList.stream()
.map(RolePO::getRoleId).collect(Collectors.toList()));
Set<String> permissionSet = permissionPOList.stream().map(PermissionPO::getPermissionName).collect(Collectors.toSet());
simpleAuthorizationInfo.setStringPermissions(permissionSet);
return simpleAuthorizationInfo;
}
那么这个权限注解是如何工作的?首先在方法上添加@RequiresPermissions。那么请求的时候就会被拦截,然后通过判断是角色注解还是权限注解而进入不同的handler处理器,例如权限的。shiro会首先获取当前的账号,同时解析注解上的权限字段。然后调用checkPermission方法去检验。
public void assertAuthorized(Annotation a) throws AuthorizationException {
if (a instanceof RequiresPermissions) {
RequiresPermissions rpAnnotation = (RequiresPermissions)a;
String[] perms = this.getAnnotationValue(a);
Subject subject = this.getSubject();
if (perms.length == 1) {
subject.checkPermission(perms[0]);
} else if (Logical.AND.equals(rpAnnotation.logical())) {
this.getSubject().checkPermissions(perms);
} else {
if (Logical.OR.equals(rpAnnotation.logical())) {
这个方法主要有两个步骤,一是检验账号,判断账号是否有效,是否还保持登录态,如果不是直接抛出未登录的异常。通过一的步骤后,在执行checkPermission,这个方法是AuthorizingRealm中的,这里要做的就是获取账号在realm中的权限实现。但是一个权限系统大多的资源都是受保护的,如果每个请求都需要去查询账号的权限的话,那是很浪费性能的,所以shiro会先从缓存中获取权限信息如果没有再会向去做查询然后,缓存这部分权限信息。这里缓存由redis实现,这个shiro-redis的依赖中默认缓存前缀shiro:cache,拼接上shiro中配置的realm的获取权限信息方法的方法全名,再拼接上后缀.authorizationCache。然后跟上我们的key,这个key用来区分是那个账号,这个key和之前配置类中redisCacheManager中
配置的PrincipalIdFieldName有关,这里会通过这个字段名获取key,所以这个字段要存在唯一性一般用账号id,缓存的过期时间默认是30分钟,可以自定义。至于权限的比对就是简单的list遍历比较了。到这里权限的验证也基本结束了。
4.前后端分离问题
上面基本就是shiro的主要功能了,在实际开发中大多是前后端分离,会遇到一些跨域,session不一致等问题,总结了下主要可以这样解决。
4.1跨域
一跨域,可以配置跨域的过滤器,不过注意这个跨域的过滤器不用配置到shiro的自定义过滤器里面,只要单独交给spring管理就可以了,声明一个过滤器实现filter,主要是设置header。
Access-Control-Allow-Credentials设置true允许跨域携带cookie时,Access-Control-Allow-Origin不能设置为*,所以一般可以设为request的origin。但是有时候,由于浏览器策略原因,(谷歌浏览器现在似乎对跨域cookie非常严格,主要是SameSite属性,这里不展开了),所以普遍的方法是使用token,因为token是无状态的,比较灵活。注意如果用了自定义token,并且携带在请求头上,那么在Access-Control-Allow-Headers要加上你的自定义请求头。
public class CorsFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.debug("corsFilter init");
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest request = WebUtils.toHttp(servletRequest);
HttpServletResponse response = WebUtils.toHttp(servletResponse);
setHeader(request, response);
filterChain.doFilter(request, response);
}
@Override
public void destroy() {
log.debug("corsFilter destroy");
}
private void setHeader(HttpServletRequest request, HttpServletResponse response) {
response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));
//需要包含自定义的token
response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept,Authorization,If-Modified-Since, auth-token");
response.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS, DELETE, PUT");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Credentials", "true");
}
4.2Options请求
二options请求。由于跨域导致浏览器发送一次options预检请求,当访问的是受保护的资源时就会有问题,那么我们需要对这个请求做放行。所以一般是重写授权过滤器。自定义一个过滤器继承FormAuthenticationFilter,其中preHandle方法如果返回true则会执行下一个过滤器,如果返回false则直接返回。所以判断如果是options方法就返回false同时设置状态为200
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest servletRequest = WebUtils.toHttp(request);
HttpServletResponse servletResponse = WebUtils.toHttp(response);
CorsFilter.setHeader(servletRequest,servletResponse);
if (servletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
servletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
另一个是isAccessAllowed方法,这个方法在父类中是判断当前的url是不是登录url以及账号当前的登录态。为了放行options请求,这里对于请求方法是options的请求直接返回true,其它方法执行父类的实现。
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (request instanceof HttpServletRequest &&
WebUtils.toHttp(request).getMethod().equals(HttpMethod.OPTIONS.name())){
return true;
}
return super.isAccessAllowed(request, response, mappedValue);
}
4.3未认证跳转问题
三未认证,shiro默认会让没有登录认证的账号跳转到登录页。但是在前后端分离的项目中,这个跳转应该让前端来完成,所以后端只需要返回一个特定的响应码就可以了
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest servletRequest = WebUtils.toHttp(request);
HttpServletResponse servletResponse = WebUtils.toHttp(response);
servletResponse.setHeader("Access-Control-Allow-Origin", servletRequest.getHeader("Origin"));
servletResponse.setHeader("Access-Control-Allow-Credentials", "true");
servletResponse.setStatus(HttpStatus.OK.value());
servletResponse.setCharacterEncoding("UTF-8");
servletResponse.setContentType("application/json");
PrintWriter printWriter = servletResponse.getWriter();
WebResponse<Object> fail = WebResponse.fail(ServiceErrorEnum.NEED_LOGIN.getErrCode(),
ServiceErrorEnum.NEED_LOGIN.getErrMsg());
printWriter.write(JSON.toJSONString(fail));
printWriter.close();
return false;
}
二和三的实现写在自定义的认证过滤器中,这个过滤器需要在之前的shiroFilterFactoryBean,以自定义过滤器的身份配置进去,直接以new 的方式注入,不需要声明成spring bean。同时要替换掉原来过滤器的“auth”
4.4Session不一致
四session不一致,由于跨域,导致前端每次请求的时候sessionId都会变化,无法辨识账号的登录态。所以我们需要重写shiro的sessionManager,我们可以在第一次登录成功后将sessionId返回给前端,然后让前端在之后的请求中将sessionId放在自定义请求头上。我们创建一个名为SelfSessionManager的类继承DefaultWebSessionManager。
@Override
protected Serializable getSessionId(ServletRequest request, ServletResponse response) {
String sessionId = WebUtils.toHttp(request).getHeader(TOKEN);
if (StringUtils.isNotBlank(sessionId)) {
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE, REFERENCED_SESSION_ID_SOURCE);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID, sessionId);
request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID, Boolean.TRUE);
return sessionId;
} else {
return super.getSessionId(request, response);
}
}
重写getSessionId的方法,首先判断token中是否存在这个值,如果有就把token里的值作为sessionId返回;如果没有,就调用父类的方法。这样等于利用了token将sessionId保持不变了。