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 {

    @Override
    public void setName(String name) {
        super.setName("RealmCommon");
    }

    /**
     * 只重写授权方法
     * @param principalCollection 身份信息集合
     * @return 授权信息
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        //1.获取认证的用户数据 | devtools冲突导致无法强转,需更改类加载器:resources/META-INF/spring-devtools.properties
        UserEntity 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
     */
    @Override
    protected 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();
    }

    @Override
    protected 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 则其值为sessionId
            request.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 {

    @Override
    protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
        return super.isAccessAllowed(request, response, mappedValue);
    }

    @Override
    protected 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
     */
    @Bean
    public RealmCommon getRealm() {
        return new RealmCommon();
    }

    /**
     * 安全管理器
     * @param realm realm域
     * @return SecurityManager
     */
    @Bean
    public 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
     */
    @Bean
    public 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
     */
    @Bean
    public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
        advisor.setSecurityManager(securityManager);
        return advisor;
    }

    @Bean
    public 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);
        //删除过期的session
        sessionManager.setDeleteInvalidSessions(true);
        //定时检查session
        sessionManager.setSessionValidationSchedulerEnabled(true);
        //可自定义sessionId
        //sessionManager.setSessionIdCookie(new SimpleCookie("fs_session"));
        return sessionManager;
    }

}
登录模块realm

登录模块添加realm实现认证

public class CustomRealm extends CommonRealm {

    @Autowired
    private UserService userService;

    @Override
    public void setName(String name) {
        super.setName("customRealm");
    }

    /**
     * 认证匹配用户是否存在
     * @param authenticationToken 		shiro subject的认证信息
     * @return 							认证成功
     * @throws AuthenticationException 	认证失败
     */
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
        //1.获取登录的token
        UsernamePasswordToken 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

/**
 * 自定义验证器
 */
@Component
public class CustomCredentialsMatcher extends SimpleCredentialsMatcher {

    @Autowired
    private 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:";

    @Override
    public 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) {
                //强转为SimplePrincipalCollection
                SimplePrincipalCollection 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
     */
    @Bean
    public 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 {

    @Override
    public 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.

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐