shiro是一个功能强大,简单的安全框架。对传统的单机系统支持较好,但与微服务整合后比较麻烦,网上资料比较散乱。本文主要介绍我做这一块儿的方法以及遇到的一些坑。
思路
微服务架构下的权限认证方案最简单的是分布式session,前端去登录认证模块请求登录,登录成功后shiro会生成session并将sessionId返回前端,session中包含用户基本信息及权限信息。shiro会将session放入redis中供其他服务查看。
实现
基本思路有了,接下来是实现步骤,
首先引入shiro相关依赖
<dependency><groupId>org.apache.shiro</groupId><artifactId>shiro-spring</artifactId><version>1.3.2</version><exclusions><exclusion><!--移除shiro-quzrtz,可能会与spring冲突--><artifactId>shiro-quartz</artifactId><groupId>org.apache.shiro</groupId></exclusion></exclusions></dependency><dependency><groupId>org.crazycake</groupId><artifactId>shiro-redis</artifactId><version>2.4.2.1-RELEASE</version></dependency>
公共realm
shiro的核心部分,包含认证和授权逻辑,此realm放在公共模块,便于其他模块授权。
/*** 公共授权realm域*/public class RealmCommon extends AuthorizingRealm {@Overridepublic void setName(String name) {super.setName("RealmCommon");}/*** 只重写授权方法* @param principalCollection 身份信息集合* @return 授权信息*/@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {//1.获取认证的用户数据 | devtools冲突导致无法强转,需更改类加载器:resources/META-INF/spring-devtools.propertiesUserEntity user = (UserEntity)principalCollection.getPrimaryPrincipal();//2.构造认证数据SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();Set<RoleEntity> roles = user.getRoleList();if (CollectionUtils.isEmpty(roles)) {//用户没有角色throw new AuthorizationException();}for (RoleEntity role:roles){//添加角色信息info.addRole(role.getRoleName());//角色权限Set<PermissionEntity> permissions = role.getPermissions();for (PermissionEntity permissionEntity : permissions) {info.addStringPermission(permissionEntity.getPermissionname());}}return info;}/*** 认证方法在登录模块中补全* @param authenticationToken* @return* @throws AuthenticationException*/@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {return null;}}
这里有个坑,如果项目中引入了spring-boot-devtools会发生报错
java.lang.ClassCastException: com.common.pojo.UserEntity cannot be cast to com.common.pojo.UserEntity
同类型无法强转。原因是shiro-redis使用的类加载器与其他类的类加载器不同,要解决这个问题有两种办法。
1).直接移除devtools依赖
2).让所有类的类加载器为同一个:在common下创建 resources/META-INF/spring-devtools.properties,修改热部署配置。
restart.include.shiro-redis=/shiro-[\\w-\\.]+jar
session管理器
自定义session管理器,指定sessionid生成方式
/*** 自定义sessionManager*/public class CommonWebSessionManager extends DefaultWebSessionManager {private static final String AUTHORIZATION = "Authorization";public CommonWebSessionManager(){super();}@Overrideprotected Serializable getSessionId(ServletRequest request, ServletResponse response){String id = WebUtils.toHttp(request).getHeader(AUTHORIZATION);if (StringUtils.isEmpty(id)){//如果没有携带id参数则按照父类的方式在cookie进行获取return super.getSessionId(request,response);}else {//如果请求头中有 authToken 则其值为sessionIdrequest.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,"header");request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID,id);request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID,Boolean.TRUE);return id;}}}
认证过滤器
自定义认证过滤器,由于shiro本来是支持传统系统的,若未登录则会默认跳到内置的login.jsp,现在项目大多采用前后端分离模式,因此需要重写过滤器,返回未登录信息给前端,由前端实现跳转。即使后端指定到前端的登录页面,也会产生许多坑。
/*** 自定义过滤器,处理shiro重定向问题* @author sunqiyan*/public class CustomAuthenticationFilter extends FormAuthenticationFilter {@Overrideprotected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {return super.isAccessAllowed(request, response, mappedValue);}@Overrideprotected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {HttpServletResponse httpServletResponse = (HttpServletResponse) response;Subject subject = SecurityUtils.getSubject();Object principal = subject.getPrincipal();if (ObjectUtils.isEmpty(principal)) {Map<String, Object> map = ResultUtil.genResult(ResultUtil.Status.NOT_LOGIN, "未登录");httpServletResponse.setCharacterEncoding("UTF-8");httpServletResponse.setContentType("application/json");httpServletResponse.getWriter().write(JSONObject.toJSONString(map, SerializerFeature.WriteMapNullValue));}return false;}}
公共shiro配置
接下来是公共的shiro配置类
/*** shiro配置类*/public class ShiroConfig {@Value("${spring.redis.host}")private String host;@Value("${spring.redis.port}")private int port;@Value("${spring.redis.password}")private String password;/*** 自定义realm* @return*/@Beanpublic RealmCommon getRealm() {return new RealmCommon();}/*** 安全管理器* @param realm realm域* @return SecurityManager*/@Beanpublic SecurityManager securityManager(RealmCommon realm) {//默认安全管理器DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(realm);//将自定义的realm交给安全管理器管理securityManager.setRealm(realm);//自定义session管理器securityManager.setSessionManager(sessionManager());//自定义缓存实现securityManager.setCacheManager(cacheManager());return securityManager;}/*** shiro过滤器工厂* @param securityManager* @return*/@Beanpublic ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {//shiro过滤器工厂ShiroFilterFactoryBean filterFactory = new ShiroFilterFactoryBean();//设置安全管理器filterFactory.setSecurityManager(securityManager);LinkedHashMap<String, Filter> filterMap = new LinkedHashMap<>();//自定义认证过滤器filterMap.put("auth",new CustomAuthenticationFilter());filterFactory.setFilters(filterMap);//设置过滤链Map<String, String> filterChainMap = new LinkedHashMap<>();//anon 游客即可访问filterChainMap.put("/css/**","anon");filterChainMap.put("/js/**","anon");filterChainMap.put("/image/**","anon");filterChainMap.put("favicon.ico","anon");//authc 需经过验证才能访问 auth自定义的过滤策略filterChainMap.put("/**","auth");filterFactory.setFilterChainDefinitionMap(filterChainMap);return filterFactory;}/*** 开启shiro aop注解支持* @param securityManager* @return*/@Beanpublic AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();advisor.setSecurityManager(securityManager);return advisor;}@Beanpublic DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();advisorAutoProxyCreator.setProxyTargetClass(true);return advisorAutoProxyCreator;}/*** redis管理器* @return*/public RedisManager redisManager(){RedisManager redisManager = new RedisManager();//设置redis ip 端口 密码redisManager.setHost(host);redisManager.setPort(port);redisManager.setPassword(password);return redisManager;}/*** 配置redis缓存管理器,用户、角色、权限实体类需序列化* @return*/public RedisCacheManager cacheManager() {RedisCacheManager redisCacheManager = new RedisCacheManager();//设置redis管理器redisCacheManager.setRedisManager(redisManager());return redisCacheManager;}/*** redisSessiondao,实现redis的增删改查,交给shiro管理,shiro使用的是jedis* 也可自定义* @return*/public RedisSessionDAO redisSessionDAO() {RedisSessionDAO redisSessionDAO = new RedisSessionDAO();redisSessionDAO.setRedisManager(redisManager());return redisSessionDAO;}/*** session管理器* @return*/public DefaultWebSessionManager sessionManager(){CommonWebSessionManager sessionManager = new CommonWebSessionManager();sessionManager.setSessionDAO(redisSessionDAO());//设置session超时时间(单位毫秒),设置为-1000L永不过期sessionManager.setGlobalSessionTimeout(1000*60*30);//删除过期的sessionsessionManager.setDeleteInvalidSessions(true);//定时检查sessionsessionManager.setSessionValidationSchedulerEnabled(true);//可自定义sessionId//sessionManager.setSessionIdCookie(new SimpleCookie("fs_session"));return sessionManager;}}
登录模块realm
登录模块添加realm实现认证
public class CustomRealm extends CommonRealm {@Autowiredprivate UserService userService;@Overridepublic void setName(String name) {super.setName("customRealm");}/*** 认证匹配用户是否存在* @param authenticationToken shiro subject的认证信息* @return 认证成功* @throws AuthenticationException 认证失败*/@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {//1.获取登录的tokenUsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;//2.获取用户名String username = token.getUsername();if (StringUtils.isBlank(username)) {//账户异常throw new AccountException("用户名不能为空");}//3.数据库查询用户UserEntity userEntity = this.userService.queryUserByName(username);if (userEntity == null) {throw new UnknownAccountException();}if (userEntity.getStatus()!=1) {//用户锁定throw new LockedAccountException();}return new SimpleAuthenticationInfo(userEntity,userEntity.getPassword(),this.getName());}}
匹配器
由于项目中需要实现异地登录顶出功能,因此需要自定义匹配器实现认证逻辑。gai
/*** 自定义验证器*/@Componentpublic class CustomCredentialsMatcher extends SimpleCredentialsMatcher {@Autowiredprivate StringRedisTemplate redisTemplate;/*** 最大重试次数*/@Value("#{'${cus.matcher.maxRetryNum:5}'}")private int maxRetryNum;/***超时时间*/@Value("#{'${cus.matcher.timeOutNum:20}'}")private int timeOutNum;/*** redis键*/private static final String PREFIX = "LOGIN_ERROR:";@Overridepublic boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {//获取token中的用户名密码UsernamePasswordToken token1 = (UsernamePasswordToken) token;String username = token1.getUsername();String password = new String(token1.getPassword());//获取凭证中的信息UserEntity user = (UserEntity)info.getPrincipals().getPrimaryPrincipal();String infoPassword = getCredentials(info).toString();//失败次数初始化AtomicInteger errorNum = new AtomicInteger(0);String o = redisTemplate.opsForValue().get(PREFIX + username);if (StringUtils.isNotBlank(o)){errorNum = new AtomicInteger(Integer.parseInt(o));}//失败次数超标if (errorNum.get() >=maxRetryNum) {throw new ExcessiveAttemptsException();}//密码校验boolean match = infoPassword.equals(password);if (match) {//登录成功,删除缓存redisTemplate.delete(PREFIX+username);DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();DefaultWebSessionManager sessionManager = (DefaultWebSessionManager) securityManager.getSessionManager();//异地登录顶出//获取在线的session,判断登录用户是否已存在 | shiro分布式session弊端,影响性能Collection<Session> sessions = sessionManager.getSessionDAO().getActiveSessions();for (Session session:sessions) {//强转为SimplePrincipalCollectionSimplePrincipalCollection attribute = (SimplePrincipalCollection)session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);if (ObjectUtils.isEmpty(attribute)) {continue;}UserEntity userEntity = (UserEntity) attribute.getPrimaryPrincipal();if (user.getUserId()==userEntity.getUserId()){//session中存在用户则删除sessionManager.getSessionDAO().delete(session);}}}else {//设置超时时间,到时自动解锁redisTemplate.opsForValue().set(PREFIX+username,errorNum.incrementAndGet()+"",timeOutNum, TimeUnit.MINUTES);throw new IncorrectCredentialsException();}return match;}}
此处实现挤出功能的方法是遍历session,用户少的情况下还行,用户多的话会影响性能,暂时没有想到解决办法。
匹配器完成后需要在登录模块的shiroConfig中设置:
/*** 自定义匹配器*/@Bean(name = "credentialsMatcher")public CredentialsMatcher customCredentialsMatcher(){return new CustomCredentialsMatcher();}/*** 自定义realm* @return*/@Beanpublic CommonRealm getRealm() {CommonRealm customRealm = new CustomRealm();customRealm.setCredentialsMatcher(customCredentialsMatcher());return customRealm;}
其他模块只需要授权功能,每个模块继承公共模块的shiroConfig即可。
feign拦截器
shiro与springcloud整合还有个坑,就是使用feign远程调用时,feign默认会过滤到cookie,导致远程调用失败。失败返回值还不为空,而是正常的对象,对象里的属性都为空,直接跳过了判空操作,这就很麻烦。因此,需要自定义个拦截器,在远程调用时将cookie设置进请求里
/*** 公共拦截器,处理feign远程调用过滤cookie问题*/public class FeignCookieInterceptor implements RequestInterceptor {@Overridepublic void apply(RequestTemplate requestTemplate) {if (null == getHttpServletRequest()){return;}requestTemplate.header("Cookie",getHttpServletRequest().getHeader("Cookie"));}private HttpServletRequest getHttpServletRequest(){try{return ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();}catch (Exception e){return null;}}}
异常处理
因为做登录功能时要求把登录失败的原因记录到日志中,因此需要捕获各种异常,需要捕获的异常有以下几类
ExcessiveAttemptsException 操作频繁异常LockedAccountException 账户锁定异常IncorrectCredentialsException 密码错误异常UnknownAccountException 未知账户异常UnknownSessionException未知session异常,该异常本来是判断session是否存在,由于在做异地登录功能时直接把session删除了,因此账户被顶出会抛出该异常。也可以不把session删除,设置session立马过期,但是我没看到效果,只好暴力删除了。
以上就是shiro+springcloud的整合过程,感觉shiro对微服务的支持不是太好。
shiro可以使用注解控制权限,但是注解的value不支持动态获取,后期万一该角色或权限会比较麻烦,暂时没找到解决办法。 shiro的注解也不支持加在类上,这也是比较坑的点。
总的来说,shiro用起来还是比较简单的,不过个人认为分布式系统还是用其他方案好些,当然大佬也可以尝试修改源码o( ̄︶ ̄)o.
