Springboot集成Shiro和Cas实现单点登录-客户端篇

前言

这里我先要说明一下,我们的项目架构是Springboot+Shiro+Ehcache+ThymeLeaf+Mybaits,在这个基础上,我们再加入了CAS单点登录,虽然前面的框架看着很长,但是和单点登录相关的核心架构其实就是Springboot和Shiro而已,所以在看这篇文章之前,需要你掌握的知识有Springboot的基础框架搭建以及集成Shiro后的一些操作,因为之后的集成CAS其实也是在这个基础上进行的修改。

引入Shiro-cas包

需要集成CAS那么肯定要引入CAS相关的组件包,在POM.xml中引入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<!-- shiro spring. -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.2.6</version>
</dependency>

<!-- shiro ehcache -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-ehcache</artifactId>
<version>1.2.6</version>
</dependency>
<!-- shiro cas -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-cas</artifactId>
<version>1.2.6</version>
</dependency>

前两个一个是Spring和Shiro结合的shiro-spring包和与ehcache结合的shiro-ehcache包,这两个包应该是之前就有的,之所以也把他们写进来是因为如果要引入CAS的组件包,需要保证这三个包的版本号一致,笔者之前引入的前两个包的版本号是1.2.4,结果单独引入1.2.6的shiro-cas包后,一些cas关键的类是找不到的,所以这里尽量保持这三个引入包的版本号一致。

小插曲
我在升级1.2.4的shiro-spring和shiro-ehcache这连个组件包的时候,是直接修改的1.2.4为1.2.6,但是引入一直报错,尝试了各种办法都不行,后来发现,你需要剪切该引入包的dependency再黏贴到pom中去,不能直接修改版本号,否则会出现引入不成功的问题,这个问题卡了我一下午,坑啊!

加入单点登录的配置

如果你在你的Springboot项目中集成过shiro框架,应该对两个自定义的类不陌生,一个是myShiroConfig另一个是myShiroRealm,这两个类其实就是用户自定义的Shiro的设置类和登录验证获取权限的管理类,在这里我将不再赘述该类如何使用,直接上集成了CAS的这两个类:
首先是设置类:

1
import com.dhcc.pa.domain.SPermission;
import com.dhcc.pa.other.shiro.MyShiroCasRealm;
import com.dhcc.pa.service.SystemService;
import com.dhcc.pa.util.PublicMsg;
import org.apache.shiro.cache.ehcache.EhCacheManager;
import org.apache.shiro.cas.CasFilter;
import org.apache.shiro.cas.CasSubjectFactory;
import org.apache.shiro.spring.LifecycleBeanPostProcessor;
import org.apache.shiro.spring.security.interceptor.AuthorizationAttributeSourceAdvisor;
import org.apache.shiro.spring.web.ShiroFilterFactoryBean;
import org.apache.shiro.web.mgt.DefaultWebSecurityManager;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.DelegatingFilterProxy;

import javax.servlet.Filter;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * @Author:贾真
 * @Description:
 * @Date:创建于上午 10:02 2017/11/29
 * @Modified:
 */
@Configuration
public class ShiroConfig {

    private static final Logger logger = LoggerFactory.getLogger(ShiroConfig.class);

    // Cas登录页面地址
    public static final String casLoginUrl = PublicMsg.CASServerUrlPrefix + "/login";
    // Cas登出页面地址
    public static final String casLogoutUrl = PublicMsg.CASServerUrlPrefix  + "/logout";

    // casFilter UrlPattern
    public static final String casFilterUrlPattern = "/";
    // 登录地址
    public static final String loginUrl = casLoginUrl + "?service=" + PublicMsg.SHIROServerUrlPrefix + casFilterUrlPattern;
    // 登出地址(casserver启用service跳转功能,需在webapps\cas\WEB-INF\cas.properties文件中启用cas.logout.followServiceRedirects=true)
    public static final String logoutUrl = casLogoutUrl+"?service="+loginUrl;

    @Bean
    public EhCacheManager getEhCacheManager() {
        EhCacheManager em = new EhCacheManager();
        em.setCacheManagerConfigFile("classpath:config/ehcache-shiro.xml");
        return em;
    }

    @Bean(name = "myShiroCasRealm")
    public MyShiroCasRealm myShiroCasRealm(EhCacheManager cacheManager) {
        MyShiroCasRealm realm = new MyShiroCasRealm();
        realm.setCacheManager(cacheManager);
        return realm;
    }

    /**
     * 注册DelegatingFilterProxy(Shiro)
     *
     * @param
     * @return
     * @author 贾真
     * @create  2016年1月13日
     */
    @Bean
    public FilterRegistrationBean filterRegistrationBean() {
        FilterRegistrationBean filterRegistration = new FilterRegistrationBean();
        filterRegistration.setFilter(new DelegatingFilterProxy("shiroFilter"));
        //  该值缺省为false,表示生命周期由SpringApplicationContext管理,设置为true则表示由ServletContainer管理
        filterRegistration.addInitParameter("targetFilterLifecycle", "true");
        filterRegistration.setEnabled(true);
        filterRegistration.addUrlPatterns("/*");
        return filterRegistration;
    }

    @Bean(name = "lifecycleBeanPostProcessor")
    public LifecycleBeanPostProcessor getLifecycleBeanPostProcessor() {
        return new LifecycleBeanPostProcessor();
    }

    @Bean
    public DefaultAdvisorAutoProxyCreator getDefaultAdvisorAutoProxyCreator() {
        DefaultAdvisorAutoProxyCreator daap = new DefaultAdvisorAutoProxyCreator();
        daap.setProxyTargetClass(true);
        return daap;
    }

    @Bean(name = "securityManager")
    public DefaultWebSecurityManager getDefaultWebSecurityManager(MyShiroCasRealm myShiroCasRealm) {
        DefaultWebSecurityManager dwsm = new DefaultWebSecurityManager();
        dwsm.setRealm(myShiroCasRealm);
//      <!-- 用户授权/认证信息Cache, 采用EhCache 缓存 -->
        dwsm.setCacheManager(getEhCacheManager());
        // 指定 SubjectFactory
        dwsm.setSubjectFactory(new CasSubjectFactory());
        return dwsm;
    }

    @Bean
    public AuthorizationAttributeSourceAdvisor getAuthorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
        AuthorizationAttributeSourceAdvisor aasa = new AuthorizationAttributeSourceAdvisor();
        aasa.setSecurityManager(securityManager);
        return aasa;
    }


    /**
     * CAS过滤器
     *
     * @return
     * @author SHANHY
     * @Date:创建于上午 10:02 2017/11/29
     */
    @Bean(name = "casFilter")
    public CasFilter getCasFilter() {
        CasFilter casFilter = new CasFilter();
        casFilter.setName("casFilter");
        casFilter.setEnabled(true);
        // 登录失败后跳转的URL,也就是 Shiro 执行 CasRealm 的 doGetAuthenticationInfo 方法向CasServer验证tiket
        casFilter.setFailureUrl(loginUrl);// 我们选择认证失败后再打开登录页面
        return casFilter;
    }

    /**
     * ShiroFilter<br/>
     * 注意这里参数中的 StudentService 和 IScoreDao 只是一个例子,因为我们在这里可以用这样的方式获取到相关访问数据库的对象,
     * 然后读取数据库相关配置,配置到 shiroFilterFactoryBean 的访问规则中。实际项目中,请使用自己的Service来处理业务逻辑。
     *
     * @param
     * @param
     * @param
     * @return
     * @author 贾真
     * @Date:创建于上午 10:02 2017/11/29
     */
    @Bean
    public ShiroFilterFactoryBean shiroFilter(DefaultWebSecurityManager securityManager, CasFilter casFilter,SystemService sysPermissionInitService) {
        ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
        // 必须设置 SecurityManager
        shiroFilterFactoryBean.setSecurityManager(securityManager);
        // 如果不设置默认会自动寻找Web工程根目录下的"/login.jsp"页面
        shiroFilterFactoryBean.setLoginUrl(loginUrl);
        // 登录成功后要跳转的连接
        shiroFilterFactoryBean.setSuccessUrl("/templete");
        shiroFilterFactoryBean.setUnauthorizedUrl("/403");

        // 添加casFilter到shiroFilter中
        Map<String, Filter> filters = new HashMap<>();
        filters.put("casFilter", casFilter);
        shiroFilterFactoryBean.setFilters(filters);
        /////////////////////// 下面这些规则配置最好配置到配置文件中 ///////////////////////
        Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();

        filterChainDefinitionMap.put(casFilterUrlPattern, "casFilter");// shiro集成cas后,首先添加该规则

        // authc:该过滤器下的页面必须验证后才能访问,它是Shiro内置的一个拦截器org.apache.shiro.web.filter.authc.FormAuthenticationFilter
        // anon:它对应的过滤器里面是空的,什么都没做
        logger.info("##################从数据库读取权限规则,加载到shiroFilter中##################");
        filterChainDefinitionMap.put("/js/**", "anon");
        filterChainDefinitionMap.put("/css/**", "anon");
        filterChainDefinitionMap.put("/bootstrapDatePicker/**", "anon");
        //阻止登录成功后下载favicon
        filterChainDefinitionMap.put("/favicon.ico", "anon");

        //从数据库获取
        List<SPermission> list = sysPermissionInitService.menuGetAll();

        for (SPermission sysPermissionInit : list) {
            if(!StringUtils.isEmpty(sysPermissionInit.getUrl())){
                filterChainDefinitionMap.put(sysPermissionInit.getUrl(),
                        "perms["+sysPermissionInit.getPermission()+"]");
            }
        }
        //配置退出过滤器,其中的具体的退出代码Shiro已经替我们实现了
        filterChainDefinitionMap.put(logoutUrl, "logout");
        filterChainDefinitionMap.put("/**", "authc");
        shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
        return shiroFilterFactoryBean;
    }

}

注释写的都比较清楚了, 我这里将不再赘述,这里只有一个知识点需要强调一下:

在这个设置类中如果需要从数据库获取用户的权限列表,一定要将对应的Service写在shiroFilter这个方法里当作一个参数来使用,而不能直接用@AutoWired将该类引入,否则使用时会报该Service空指针的异常,至于原因我也不是很清楚….待查

之后是登录验证和权限获取类:

1
import com.dhcc.pa.domain.Role;
import com.dhcc.pa.domain.SUser;
import com.dhcc.pa.other.config.ShiroConfig;
import com.dhcc.pa.service.UserService;
import com.dhcc.pa.util.PublicMsg;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.cas.CasRealm;
import org.apache.shiro.subject.PrincipalCollection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.StringUtils;

import javax.annotation.PostConstruct;
import java.util.List;

/**
 * @Author:贾真
 * @Description:
 * @Date:创建于上午 10:03 2017/11/29
 * @Modified:
 */
public class MyShiroCasRealm extends CasRealm {

    private static final Logger logger = LoggerFactory.getLogger(MyShiroCasRealm.class);

    @Autowired
    private UserService userService;

    @PostConstruct
    public void initProperty(){
//      setDefaultRoles("ROLE_USER");
        setCasServerUrlPrefix(PublicMsg.CASServerUrlPrefix);
        // 客户端回调地址
        setCasService(PublicMsg.SHIROServerUrlPrefix + ShiroConfig.casFilterUrlPattern);
    }

    /**
     * 权限认证,为当前登录的Subject授予角色和权限
     * @see :本例中该方法的调用时机为需授权资源被访问时
     * @see :并且每次访问需授权资源时都会执行该方法中的逻辑,这表明本例中默认并未启用AuthorizationCache
     * @see :如果连续访问同一个URL(比如刷新),该方法不会被重复调用,Shiro有一个时间间隔(也就是cache时间,在ehcache-shiro.xml中配置),超过这个时间间隔再刷新页面,该方法会被执行
     */
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
        logger.info("##################执行Shiro权限认证##################");
        //获取用户的输入的账号.
        //获取当前登录输入的用户名,等价于(String) principalCollection.fromRealm(getName()).iterator().next();
        String username = (String)super.getAvailablePrincipal(principalCollection);
        //到数据库查是否有此对象
        List<SUser> userList = userService.findByUsername(username);
        System.out.println("----->>userInfo=" + userList.size());
        if (userList.size()==0) {
            return null;
        }

        //账号判断;
        //凌海天2017 -11-14 修改
        SUser user= userList.get(0);
        if(user!=null){
            //权限信息对象info,用来存放查出的用户的所有的角色(role)及权限(permission)
            SimpleAuthorizationInfo info=new SimpleAuthorizationInfo();
            int id  = user.getId().intValue();
            //凌海天2017 -11-14 修改
            List<Role> role = userService.findByUserid(id);
            for (Role r :role){
                //用户的角色集合
                if(!StringUtils.isEmpty(r.getRole())){
                    info.addRole(r.getRole());
                }
                //用户的角色对应的所有权限,如果只使用角色定义访问权限
                if(!StringUtils.isEmpty(r.getPermission())){
                    info.addStringPermission(r.getPermission());
                }
            }
            // 或者按下面这样添加
            //添加一个角色,不是配置意义上的添加,而是证明该用户拥有admin角色
//            simpleAuthorInfo.addRole("admin");
            //添加权限
//            simpleAuthorInfo.addStringPermission("admin:manage");
//            logger.info("已为用户[mike]赋予了[admin]角色和[admin:manage]权限");
            return info;
        }
        // 返回null的话,就会导致任何用户访问被拦截的请求时,都会自动跳转到unauthorizedUrl指定的地址
        return null;
    }


}

这两个类中都用到了PublicMsg类,这个类里主要设置的是CAS的服务端路径和本项目的对外路径,其实就两个参数:

1
//CAS服务器地址
public static final String CASServerUrlPrefix = "http://xxx.xx.xx.xxx:9092/cas";
// 当前工程对外提供的服务地址
public static final String SHIROServerUrlPrefix = "http://127.0.0.1:9091";

读者可以直接放置到设置类中,我这里单独提出来是因为我的项目专门有一个类管理这些参数而已。

查看效果

在启动CAS服务端的情况下,启动本项目,然后再浏览器中输入:
http://localhost:9091
浏览器的url路径会自动转化为:
http://172.18.18.25:9092/cas/login?service=http://127.0.0.1:9091/
这是一个CAS特有的URL路径,它的界面如下:

logo

之后在这个界面登录正确的用户名和密码后,系统会自动跳转到项目的主页中去。

获取用户信息

在你不在服务端做任何设置的默认情况下,CAS服务端只会给客户端返回一个用户名,比如你的服务端的用户名是admin,只要你登录成功,就会把服务端的用户名传递给客户端,客户端通过:

1
Subject currentUser = SecurityUtils.getSubject();
String username = currentUser.getPrincipal().toString();

这两行代码就可以获取到登录用户的用户名,然后再通过自己写的通过用户名获取用户信息的Service就可以获取到相关的用户信息了,这里应该不难理解。

至于获取用户的多属性,就要结合到之前的服务端的设置了,首先你要在服务端设置如下参数:

1
#多属性
cas.authn.attributeRepository.jdbc[0].singleRow=true
cas.authn.attributeRepository.jdbc[0].order=0
cas.authn.attributeRepository.jdbc[0].url=jdbc:mysql://172.18.18.25:3306/pa_db?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&useSSL=false
cas.authn.attributeRepository.jdbc[0].username=username
cas.authn.attributeRepository.jdbc[0].user=root
cas.authn.attributeRepository.jdbc[0].password=1234
cas.authn.attributeRepository.jdbc[0].sql=select * from s_user where {0}
cas.authn.attributeRepository.jdbc[0].dialect=org.hibernate.dialect.MySQLDialect
cas.authn.attributeRepository.jdbc[0].ddlAuto=none
cas.authn.attributeRepository.jdbc[0].driverClass=com.mysql.jdbc.Driver
cas.authn.attributeRepository.jdbc[0].leakThreshold=10
cas.authn.attributeRepository.jdbc[0].propagationBehaviorName=PROPAGATION_REQUIRED
cas.authn.attributeRepository.jdbc[0].batchSize=1
cas.authn.attributeRepository.jdbc[0].healthQuery=SELECT 1
cas.authn.attributeRepository.jdbc[0].failFast=trueyeshi

以上代码就允许用户返回服务端的s_user 数据库表中的所有字段,当然你再客户端的写法也要跟着改变:

1
 AttributePrincipal principal = (AttributePrincipal) request.getUserPrincipal();

 final Map attributes = principal.getAttributes();

后记

CAS客户端的配置差不多就是这样了,注释写的都比较明白了,需要注意的坑有以下两点:

  1. 设置类中的Service引入方式
  2. POM.xml中更改组件版本号一定要剪切黏贴,不要直接修改版本号

剩下的大家看着文章一步一步的走出来应该问题就不大了,下一篇我们讲两个小的内容:

  1. 修改CAS服务端的默认登录页
  2. 如何登出CAS客户端

敬请期待

相关资料

  1. spring boot整合Shiro实现单点登录
  2. Spring Boot 集成Shiro和CAS

热评文章