shiro是一个功能强大,简单的安全框架。对传统的单机系统支持较好,但与微服务整合后比较麻烦,网上资料比较散乱。本文主要介绍我做这一块儿的方法以及遇到的一些坑。

思路

微服务架构下的权限认证方案最简单的是分布式session,前端去登录认证模块请求登录,登录成功后shiro会生成session并将sessionId返回前端,session中包含用户基本信息及权限信息。shiro会将session放入redis中供其他服务查看。
spring cloud + shiro 权限认证 - 图1

实现

基本思路有了,接下来是实现步骤,
首先引入shiro相关依赖

  1. <dependency>
  2. <groupId>org.apache.shiro</groupId>
  3. <artifactId>shiro-spring</artifactId>
  4. <version>1.3.2</version>
  5. <exclusions>
  6. <exclusion>
  7. <!--移除shiro-quzrtz,可能会与spring冲突-->
  8. <artifactId>shiro-quartz</artifactId>
  9. <groupId>org.apache.shiro</groupId>
  10. </exclusion>
  11. </exclusions>
  12. </dependency>
  13. <dependency>
  14. <groupId>org.crazycake</groupId>
  15. <artifactId>shiro-redis</artifactId>
  16. <version>2.4.2.1-RELEASE</version>
  17. </dependency>

公共realm

shiro的核心部分,包含认证和授权逻辑,此realm放在公共模块,便于其他模块授权。

  1. /**
  2. * 公共授权realm域
  3. */
  4. public class RealmCommon extends AuthorizingRealm {
  5. @Override
  6. public void setName(String name) {
  7. super.setName("RealmCommon");
  8. }
  9. /**
  10. * 只重写授权方法
  11. * @param principalCollection 身份信息集合
  12. * @return 授权信息
  13. */
  14. @Override
  15. protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
  16. //1.获取认证的用户数据 | devtools冲突导致无法强转,需更改类加载器:resources/META-INF/spring-devtools.properties
  17. UserEntity user = (UserEntity)principalCollection.getPrimaryPrincipal();
  18. //2.构造认证数据
  19. SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
  20. Set<RoleEntity> roles = user.getRoleList();
  21. if (CollectionUtils.isEmpty(roles)) {
  22. //用户没有角色
  23. throw new AuthorizationException();
  24. }
  25. for (RoleEntity role:roles){
  26. //添加角色信息
  27. info.addRole(role.getRoleName());
  28. //角色权限
  29. Set<PermissionEntity> permissions = role.getPermissions();
  30. for (PermissionEntity permissionEntity : permissions) {
  31. info.addStringPermission(permissionEntity.getPermissionname());
  32. }
  33. }
  34. return info;
  35. }
  36. /**
  37. * 认证方法在登录模块中补全
  38. * @param authenticationToken
  39. * @return
  40. * @throws AuthenticationException
  41. */
  42. @Override
  43. protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
  44. return null;
  45. }
  46. }

这里有个坑,如果项目中引入了spring-boot-devtools会发生报错

  1. 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,修改热部署配置。

  1. restart.include.shiro-redis=/shiro-[\\w-\\.]+jar

session管理器

自定义session管理器,指定sessionid生成方式

  1. /**
  2. * 自定义sessionManager
  3. */
  4. public class CommonWebSessionManager extends DefaultWebSessionManager {
  5. private static final String AUTHORIZATION = "Authorization";
  6. public CommonWebSessionManager(){
  7. super();
  8. }
  9. @Override
  10. protected Serializable getSessionId(ServletRequest request, ServletResponse response){
  11. String id = WebUtils.toHttp(request).getHeader(AUTHORIZATION);
  12. if (StringUtils.isEmpty(id)){
  13. //如果没有携带id参数则按照父类的方式在cookie进行获取
  14. return super.getSessionId(request,response);
  15. }else {
  16. //如果请求头中有 authToken 则其值为sessionId
  17. request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_SOURCE,"header");
  18. request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID,id);
  19. request.setAttribute(ShiroHttpServletRequest.REFERENCED_SESSION_ID_IS_VALID,Boolean.TRUE);
  20. return id;
  21. }
  22. }
  23. }

认证过滤器

自定义认证过滤器,由于shiro本来是支持传统系统的,若未登录则会默认跳到内置的login.jsp,现在项目大多采用前后端分离模式,因此需要重写过滤器,返回未登录信息给前端,由前端实现跳转。即使后端指定到前端的登录页面,也会产生许多坑。

  1. /**
  2. * 自定义过滤器,处理shiro重定向问题
  3. * @author sunqiyan
  4. */
  5. public class CustomAuthenticationFilter extends FormAuthenticationFilter {
  6. @Override
  7. protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
  8. return super.isAccessAllowed(request, response, mappedValue);
  9. }
  10. @Override
  11. protected boolean onAccessDenied(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
  12. HttpServletResponse httpServletResponse = (HttpServletResponse) response;
  13. Subject subject = SecurityUtils.getSubject();
  14. Object principal = subject.getPrincipal();
  15. if (ObjectUtils.isEmpty(principal)) {
  16. Map<String, Object> map = ResultUtil.genResult(ResultUtil.Status.NOT_LOGIN, "未登录");
  17. httpServletResponse.setCharacterEncoding("UTF-8");
  18. httpServletResponse.setContentType("application/json");
  19. httpServletResponse.getWriter().write(JSONObject.toJSONString(map, SerializerFeature.WriteMapNullValue));
  20. }
  21. return false;
  22. }
  23. }

公共shiro配置

接下来是公共的shiro配置类

  1. /**
  2. * shiro配置类
  3. */
  4. public class ShiroConfig {
  5. @Value("${spring.redis.host}")
  6. private String host;
  7. @Value("${spring.redis.port}")
  8. private int port;
  9. @Value("${spring.redis.password}")
  10. private String password;
  11. /**
  12. * 自定义realm
  13. * @return
  14. */
  15. @Bean
  16. public RealmCommon getRealm() {
  17. return new RealmCommon();
  18. }
  19. /**
  20. * 安全管理器
  21. * @param realm realm域
  22. * @return SecurityManager
  23. */
  24. @Bean
  25. public SecurityManager securityManager(RealmCommon realm) {
  26. //默认安全管理器
  27. DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(realm);
  28. //将自定义的realm交给安全管理器管理
  29. securityManager.setRealm(realm);
  30. //自定义session管理器
  31. securityManager.setSessionManager(sessionManager());
  32. //自定义缓存实现
  33. securityManager.setCacheManager(cacheManager());
  34. return securityManager;
  35. }
  36. /**
  37. * shiro过滤器工厂
  38. * @param securityManager
  39. * @return
  40. */
  41. @Bean
  42. public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) {
  43. //shiro过滤器工厂
  44. ShiroFilterFactoryBean filterFactory = new ShiroFilterFactoryBean();
  45. //设置安全管理器
  46. filterFactory.setSecurityManager(securityManager);
  47. LinkedHashMap<String, Filter> filterMap = new LinkedHashMap<>();
  48. //自定义认证过滤器
  49. filterMap.put("auth",new CustomAuthenticationFilter());
  50. filterFactory.setFilters(filterMap);
  51. //设置过滤链
  52. Map<String, String> filterChainMap = new LinkedHashMap<>();
  53. //anon 游客即可访问
  54. filterChainMap.put("/css/**","anon");
  55. filterChainMap.put("/js/**","anon");
  56. filterChainMap.put("/image/**","anon");
  57. filterChainMap.put("favicon.ico","anon");
  58. //authc 需经过验证才能访问 auth自定义的过滤策略
  59. filterChainMap.put("/**","auth");
  60. filterFactory.setFilterChainDefinitionMap(filterChainMap);
  61. return filterFactory;
  62. }
  63. /**
  64. * 开启shiro aop注解支持
  65. * @param securityManager
  66. * @return
  67. */
  68. @Bean
  69. public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
  70. AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
  71. advisor.setSecurityManager(securityManager);
  72. return advisor;
  73. }
  74. @Bean
  75. public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator(){
  76. DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
  77. advisorAutoProxyCreator.setProxyTargetClass(true);
  78. return advisorAutoProxyCreator;
  79. }
  80. /**
  81. * redis管理器
  82. * @return
  83. */
  84. public RedisManager redisManager(){
  85. RedisManager redisManager = new RedisManager();
  86. //设置redis ip 端口 密码
  87. redisManager.setHost(host);
  88. redisManager.setPort(port);
  89. redisManager.setPassword(password);
  90. return redisManager;
  91. }
  92. /**
  93. * 配置redis缓存管理器,用户、角色、权限实体类需序列化
  94. * @return
  95. */
  96. public RedisCacheManager cacheManager() {
  97. RedisCacheManager redisCacheManager = new RedisCacheManager();
  98. //设置redis管理器
  99. redisCacheManager.setRedisManager(redisManager());
  100. return redisCacheManager;
  101. }
  102. /**
  103. * redisSessiondao,实现redis的增删改查,交给shiro管理,shiro使用的是jedis
  104. * 也可自定义
  105. * @return
  106. */
  107. public RedisSessionDAO redisSessionDAO() {
  108. RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
  109. redisSessionDAO.setRedisManager(redisManager());
  110. return redisSessionDAO;
  111. }
  112. /**
  113. * session管理器
  114. * @return
  115. */
  116. public DefaultWebSessionManager sessionManager(){
  117. CommonWebSessionManager sessionManager = new CommonWebSessionManager();
  118. sessionManager.setSessionDAO(redisSessionDAO());
  119. //设置session超时时间(单位毫秒),设置为-1000L永不过期
  120. sessionManager.setGlobalSessionTimeout(1000*60*30);
  121. //删除过期的session
  122. sessionManager.setDeleteInvalidSessions(true);
  123. //定时检查session
  124. sessionManager.setSessionValidationSchedulerEnabled(true);
  125. //可自定义sessionId
  126. //sessionManager.setSessionIdCookie(new SimpleCookie("fs_session"));
  127. return sessionManager;
  128. }
  129. }

登录模块realm

登录模块添加realm实现认证

  1. public class CustomRealm extends CommonRealm {
  2. @Autowired
  3. private UserService userService;
  4. @Override
  5. public void setName(String name) {
  6. super.setName("customRealm");
  7. }
  8. /**
  9. * 认证匹配用户是否存在
  10. * @param authenticationToken shiro subject的认证信息
  11. * @return 认证成功
  12. * @throws AuthenticationException 认证失败
  13. */
  14. @Override
  15. protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
  16. //1.获取登录的token
  17. UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
  18. //2.获取用户名
  19. String username = token.getUsername();
  20. if (StringUtils.isBlank(username)) {
  21. //账户异常
  22. throw new AccountException("用户名不能为空");
  23. }
  24. //3.数据库查询用户
  25. UserEntity userEntity = this.userService.queryUserByName(username);
  26. if (userEntity == null) {
  27. throw new UnknownAccountException();
  28. }
  29. if (userEntity.getStatus()!=1) {
  30. //用户锁定
  31. throw new LockedAccountException();
  32. }
  33. return new SimpleAuthenticationInfo(userEntity,userEntity.getPassword(),this.getName());
  34. }
  35. }

匹配器

由于项目中需要实现异地登录顶出功能,因此需要自定义匹配器实现认证逻辑。gai

  1. /**
  2. * 自定义验证器
  3. */
  4. @Component
  5. public class CustomCredentialsMatcher extends SimpleCredentialsMatcher {
  6. @Autowired
  7. private StringRedisTemplate redisTemplate;
  8. /**
  9. * 最大重试次数
  10. */
  11. @Value("#{'${cus.matcher.maxRetryNum:5}'}")
  12. private int maxRetryNum;
  13. /**
  14. *超时时间
  15. */
  16. @Value("#{'${cus.matcher.timeOutNum:20}'}")
  17. private int timeOutNum;
  18. /**
  19. * redis键
  20. */
  21. private static final String PREFIX = "LOGIN_ERROR:";
  22. @Override
  23. public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) {
  24. //获取token中的用户名密码
  25. UsernamePasswordToken token1 = (UsernamePasswordToken) token;
  26. String username = token1.getUsername();
  27. String password = new String(token1.getPassword());
  28. //获取凭证中的信息
  29. UserEntity user = (UserEntity)info.getPrincipals().getPrimaryPrincipal();
  30. String infoPassword = getCredentials(info).toString();
  31. //失败次数初始化
  32. AtomicInteger errorNum = new AtomicInteger(0);
  33. String o = redisTemplate.opsForValue().get(PREFIX + username);
  34. if (StringUtils.isNotBlank(o)){
  35. errorNum = new AtomicInteger(Integer.parseInt(o));
  36. }
  37. //失败次数超标
  38. if (errorNum.get() >=maxRetryNum) {
  39. throw new ExcessiveAttemptsException();
  40. }
  41. //密码校验
  42. boolean match = infoPassword.equals(password);
  43. if (match) {
  44. //登录成功,删除缓存
  45. redisTemplate.delete(PREFIX+username);
  46. DefaultWebSecurityManager securityManager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager();
  47. DefaultWebSessionManager sessionManager = (DefaultWebSessionManager) securityManager.getSessionManager();
  48. //异地登录顶出
  49. //获取在线的session,判断登录用户是否已存在 | shiro分布式session弊端,影响性能
  50. Collection<Session> sessions = sessionManager.getSessionDAO().getActiveSessions();
  51. for (Session session:sessions) {
  52. //强转为SimplePrincipalCollection
  53. SimplePrincipalCollection attribute = (SimplePrincipalCollection)session.getAttribute(DefaultSubjectContext.PRINCIPALS_SESSION_KEY);
  54. if (ObjectUtils.isEmpty(attribute)) {
  55. continue;
  56. }
  57. UserEntity userEntity = (UserEntity) attribute.getPrimaryPrincipal();
  58. if (user.getUserId()==userEntity.getUserId()){
  59. //session中存在用户则删除
  60. sessionManager.getSessionDAO().delete(session);
  61. }
  62. }
  63. }else {
  64. //设置超时时间,到时自动解锁
  65. redisTemplate.opsForValue().set(PREFIX+username,errorNum.incrementAndGet()+"",timeOutNum, TimeUnit.MINUTES);
  66. throw new IncorrectCredentialsException();
  67. }
  68. return match;
  69. }
  70. }

此处实现挤出功能的方法是遍历session,用户少的情况下还行,用户多的话会影响性能,暂时没有想到解决办法。
匹配器完成后需要在登录模块shiroConfig中设置:

  1. /**
  2. * 自定义匹配器
  3. */
  4. @Bean(name = "credentialsMatcher")
  5. public CredentialsMatcher customCredentialsMatcher(){
  6. return new CustomCredentialsMatcher();
  7. }
  8. /**
  9. * 自定义realm
  10. * @return
  11. */
  12. @Bean
  13. public CommonRealm getRealm() {
  14. CommonRealm customRealm = new CustomRealm();
  15. customRealm.setCredentialsMatcher(customCredentialsMatcher());
  16. return customRealm;
  17. }

其他模块只需要授权功能,每个模块继承公共模块的shiroConfig即可。

feign拦截器

shiro与springcloud整合还有个坑,就是使用feign远程调用时,feign默认会过滤到cookie,导致远程调用失败。失败返回值还不为空,而是正常的对象,对象里的属性都为空,直接跳过了判空操作,这就很麻烦。因此,需要自定义个拦截器,在远程调用时将cookie设置进请求里

  1. /**
  2. * 公共拦截器,处理feign远程调用过滤cookie问题
  3. */
  4. public class FeignCookieInterceptor implements RequestInterceptor {
  5. @Override
  6. public void apply(RequestTemplate requestTemplate) {
  7. if (null == getHttpServletRequest()){
  8. return;
  9. }
  10. requestTemplate.header("Cookie",getHttpServletRequest().getHeader("Cookie"));
  11. }
  12. private HttpServletRequest getHttpServletRequest(){
  13. try{
  14. return ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
  15. }catch (Exception e){
  16. return null;
  17. }
  18. }
  19. }

此拦截器也可放入公共模块中,在其他模块使用是注入即可。

异常处理

因为做登录功能时要求把登录失败的原因记录到日志中,因此需要捕获各种异常,需要捕获的异常有以下几类

  1. ExcessiveAttemptsException 操作频繁异常
  2. LockedAccountException 账户锁定异常
  3. IncorrectCredentialsException 密码错误异常
  4. UnknownAccountException 未知账户异常
  5. UnknownSessionException
  6. 未知session异常,该异常本来是判断session是否存在,
  7. 由于在做异地登录功能时直接把session删除了,因此账户被顶出会抛出该异常。
  8. 也可以不把session删除,设置session立马过期,但是我没看到效果,只好暴力删除了。

以上就是shiro+springcloud的整合过程,感觉shiro对微服务的支持不是太好。

shiro可以使用注解控制权限,但是注解的value不支持动态获取,后期万一该角色或权限会比较麻烦,暂时没找到解决办法。 shiro的注解也不支持加在类上,这也是比较坑的点。

总的来说,shiro用起来还是比较简单的,不过个人认为分布式系统还是用其他方案好些,当然大佬也可以尝试修改源码o( ̄︶ ̄)o.