Springboot security cas整合方案-实践篇

承接前文Springboot security cas整合方案-原理篇,请在理解原理的情况下再查看实践篇

maven环境

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <!-- 添加spring security cas支持 -->
        <dependency>
        	<groupId>org.springframework.security</groupId>
        	<artifactId>spring-security-cas</artifactId>
        </dependency>

cas基础配置

包含配置文件以及对应的VO类

  1. src/main/resources/application-cas.yml
    cas:
     server:
       host:
        url: http://192.168.1.101/cas #cas服务地址
        login_url: /login #登录地址
        logout_url: /logout #注销地址

    app:
     server:
      host:
        url: http://localhost:8080/web-cas #本应用访问地址
     login:
        url: /login/cas	#本应用登录地址
     logout:
        url: /logout #本应用退出地址
  1. 对应的VO类,应用@Component注解加载
    @Component
    public class AcmCasProperties {

	@Value("${cas.server.host.url}")
	private String casServerPrefix;

	@Value("${cas.server.host.login_url}")
	private String casServerLoginUrl;

	@Value("${cas.server.host.logout_url}")
	private String casServerLogoutUrl;

	@Value("${app.server.host.url}")
	private String appServicePrefix;

	@Value("${app.login.url}")
	private String appServiceLoginUrl;

	@Value("${app.logout.url}")
	private String appServiceLogoutUrl;

	public String getCasServerPrefix() {
		return LocalIpUtil.replaceTrueIpIfLocalhost(casServerPrefix);
	}

	public void setCasServerPrefix(String casServerPrefix) {
		this.casServerPrefix = casServerPrefix;
	}

	public String getCasServerLoginUrl() {
		return casServerLoginUrl;
	}

	public void setCasServerLoginUrl(String casServerLoginUrl) {
		this.casServerLoginUrl = casServerLoginUrl;
	}

	public String getCasServerLogoutUrl() {
		return casServerLogoutUrl;
	}

	public void setCasServerLogoutUrl(String casServerLogoutUrl) {
		this.casServerLogoutUrl = casServerLogoutUrl;
	}

	public String getAppServicePrefix() {
		return LocalIpUtil.replaceTrueIpIfLocalhost(appServicePrefix);
	}

	public void setAppServicePrefix(String appServicePrefix) {
		this.appServicePrefix = appServicePrefix;
	}

	public String getAppServiceLoginUrl() {
		return appServiceLoginUrl;
	}

	public void setAppServiceLoginUrl(String appServiceLoginUrl) {
		this.appServiceLoginUrl = appServiceLoginUrl;
	}

	public String getAppServiceLogoutUrl() {
		return appServiceLogoutUrl;
	}

	public void setAppServiceLogoutUrl(String appServiceLogoutUrl) {
		this.appServiceLogoutUrl = appServiceLogoutUrl;
	}

    }
  1. 其中用到了LocalIpUtil工具类,主要是替换localhost或者域名为真实的ip
    public class LocalIpUtil
    {
      private static Logger logger = LoggerFactory.getLogger(LocalIpUtil.class);
      private static final String WINDOWS = "WINDOWS";

      public static void main(String[] args)
      {
        String url = "http://127.0.0.1:8080/client1";

        System.out.println(replaceTrueIpIfLocalhost(url));
      }

      public static String replaceTrueIpIfLocalhost(String url) {
        String localIp = getLocalIp();

        if ((url.contains("localhost")) || (url.contains("127.0.0.1"))) {
          url = url.replaceAll("localhost", localIp).replaceAll("127.0.0.1", localIp);
        }
        return url;
      }

      private static String getLocalIp()
      {
        String os = System.getProperty("os.name").toUpperCase();
        String address = "";
        if (os.contains("WINDOWS"))
          try {
            address = InetAddress.getLocalHost().getHostAddress();
          } catch (UnknownHostException e) {
            logger.error("windows获取本地IP出错", e);
          }
        else {
          address = getLinuxIP();
        }
        return address;
      }

      private static String getLinuxIP()
      {
        String address = "";
        try
        {
          Enumeration allNetInterfaces = NetworkInterface.getNetworkInterfaces();
          InetAddress ip = null;
          while (allNetInterfaces.hasMoreElements()) {
            NetworkInterface netInterface = (NetworkInterface)allNetInterfaces.nextElement();
            if ((netInterface.isUp()) && (!netInterface.isLoopback()) && (!netInterface.isVirtual()))
            {
              Enumeration addresses = netInterface.getInetAddresses();
              while (addresses.hasMoreElements()) {
                ip = (InetAddress)addresses.nextElement();
                if ((!ip.isLoopbackAddress()) && 
                  (ip != null) && ((ip instanceof Inet4Address)))
                  address = ip.getHostAddress();
              }
            }
          }
        } catch (SocketException e) {
          logger.error("linux获取本地IP出错", e);
        }
        return address;
  }

Springboot 应用cas配置

src/main/resources/application.yml应用application-cas.yml

	spring:
	  profiles:
	    active: cas

Springboot 配置cas过滤链

这里采用@Configuration@Bean注解来完成,包括LogoutFilterSingleSignOutFilterticket校验器service配置对象cas凭证校验器ProviderCasAuthenticationEntryPoint-cas认证入口

@Configuration
public class AcmCasConfiguration {

	@Resource
	private AcmCasProperties acmCasProperties;

	/**
	 * 设置客户端service的属性
	 * <p>
	 * 主要设置请求cas服务端后的回调路径,一般为主页地址,不可为登录地址
	 * 
	 * </p>
	 * 
	 * @return
	 */
	@Bean
	public ServiceProperties serviceProperties() {
		ServiceProperties serviceProperties = new ServiceProperties();
		// 设置回调的service路径,此为主页路径
		serviceProperties.setService(acmCasProperties.getAppServicePrefix() + "/index.html");
		// 对所有的未拥有ticket的访问均需要验证
		serviceProperties.setAuthenticateAllArtifacts(true);

		return serviceProperties;
	}

	/**
	 * 配置ticket校验器
	 * 
	 * @return
	 */
	@Bean
	public Cas20ServiceTicketValidator cas20ServiceTicketValidator() {
		// 配置上服务端的校验ticket地址
		return new Cas20ServiceTicketValidator(acmCasProperties.getCasServerPrefix());
	}

	/**
	 * 单点注销,接受cas服务端发出的注销session请求
	 * 
	 * @see SingleLogout(SLO) Front or Back Channel
	 * 
	 * @return
	 */
	@Bean
	public SingleSignOutFilter singleSignOutFilter() {
		SingleSignOutFilter outFilter = new SingleSignOutFilter();
		// 设置cas服务端路径前缀,应用于front channel的注销请求
		outFilter.setCasServerUrlPrefix(acmCasProperties.getCasServerPrefix());
		outFilter.setIgnoreInitConfiguration(true);

		return outFilter;
	}

	/**
	 * 单点请求cas客户端退出Filter类
	 * 
	 * 请求/logout,转发至cas服务端进行注销
	 */
	@Bean
	public LogoutFilter logoutFilter() {
		// 设置回调地址,以免注销后页面不再跳转
		StringBuilder logoutRedirectPath = new StringBuilder();
		logoutRedirectPath.append(acmCasProperties.getCasServerPrefix())
				.append(acmCasProperties.getCasServerLogoutUrl()).append("?service=")
				.append(acmCasProperties.getAppServicePrefix());

		LogoutFilter logoutFilter = new LogoutFilter(logoutRedirectPath.toString(), new SecurityContextLogoutHandler());

		logoutFilter.setFilterProcessesUrl(acmCasProperties.getAppServiceLogoutUrl());
		return logoutFilter;
	}

	/**
	 * 创建cas校验类
	 * 
	 * <p>
	 * <b>Notes:</b> TicketValidator、AuthenticationUserDetailService属性必须设置;
	 * serviceProperties属性主要应用于ticketValidator用于去cas服务端检验ticket
	 * </p>
	 * 
	 * @return
	 */
	@Bean("casProvider")
	public CasAuthenticationProvider casAuthenticationProvider(
			AuthenticationUserDetailsService<CasAssertionAuthenticationToken> userDetailsService) {
		CasAuthenticationProvider provider = new CasAuthenticationProvider();
		provider.setKey("casProvider");
		provider.setServiceProperties(serviceProperties());
		provider.setTicketValidator(cas20ServiceTicketValidator());
		provider.setAuthenticationUserDetailsService(userDetailsService);

		return provider;
	}

	/**
	 * ==============================================================
	 * ==============================================================
	 */

	/**
	 * 认证的入口,即跳转至服务端的cas地址
	 * 
	 * <p>
	 * <b>Note:</b>浏览器访问不可直接填客户端的login请求,若如此则会返回Error页面,无法被此入口拦截
	 * </p>
	 */
	@Bean
	public CasAuthenticationEntryPoint casAuthenticationEntryPoint() {
		CasAuthenticationEntryPoint entryPoint = new CasAuthenticationEntryPoint();
		entryPoint.setServiceProperties(serviceProperties());
		entryPoint.setLoginUrl(acmCasProperties.getCasServerPrefix() + acmCasProperties.getCasServerLoginUrl());

		return entryPoint;
	}
}

下面对上述的AuthenticationUserDetailsService需要手动配置下,用于权限集合的获取

配置cas获取权限集合的AuthenticationUserDetailsService

@Component
public class AcmCasUserDetailService implements AuthenticationUserDetailsService<CasAssertionAuthenticationToken> {

	private static final Logger USER_SERVICE_LOGGER = LoggerFactory.getLogger(AcmCasUserDetailService.class);

	@Resource
	private TSysUserDao tsysUserDAO;

	@Override
	public UserDetails loadUserDetails(CasAssertionAuthenticationToken token) throws UsernameNotFoundException {
		USER_SERVICE_LOGGER.info("校验成功的登录名为: " + token.getName());
		//此处涉及到数据库操作然后读取权限集合,读者可自行实现
		SysUser sysUser = tsysUserDAO.findByUserName(token.getName());
		if (null == sysUser) {
			throw new UsernameNotFoundException("username isn't exsited in log-cms");
		}
		return sysUser;
	}

}

示例中的SysUser实现了UserDetail接口,实现的方法代码如下

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> auths = new ArrayList<>();
	    //获取用户对应的角色集合
        List<SysRole> roles = this.getSysRoles();
        for (SysRole role : roles) {
	        //手动加上ROLE_前缀
            auths.add(new SimpleGrantedAuthority(SercurityConstants.prefix+role.getRoleName()));
        }
        return auths;
    }

FilterSecurityInterceptor配置

需要配置权限的认证过滤链

@Component
public class CasFilterSecurityInterceptor extends AbstractSecurityInterceptor implements Filter {
    @Resource
    private FilterInvocationSecurityMetadataSource securityMetadataSource;

    @Resource
    public void setMyAccessDecisionManager(AccessDecisionManager myAccessDecisionManager) {
        super.setAccessDecisionManager(myAccessDecisionManager);
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain);
        invoke(fi);
    }
    private void invoke(FilterInvocation fi) throws IOException, ServletException {
        //fi里面有一个被拦截的url
        //里面调用CasInvocationSecurityMetadataSource的getAttributes(Object object)这个方法获取fi对应的所有权限
        //再调用CasAccessDecisionManager的decide方法来校验用户的权限是否足够
        InterceptorStatusToken token = super.beforeInvocation(fi);
        try {
        //执行下一个拦截器
            fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        } finally {
            super.afterInvocation(token, null);
        }
    }

    @Override
    public void destroy() {

    }

    @Override
    public Class<?> getSecureObjectClass() {
        return FilterInvocation.class;
    }

    @Override
    public SecurityMetadataSource obtainSecurityMetadataSource() {
        return this.securityMetadataSource;
    }
}

其中还涉及到SecurityMetadataSource-当前访问路径的权限获取AccessDecisionManager-授权处理器

SecurityMetadataSource-当前访问路径的权限获取

@Component
public class CasInvocationSecurityMetadataSourceService implements FilterInvocationSecurityMetadataSource {
    private final TSysMenuDao tSysMenuDao;
    private final HashSet<Pattern> patterns;
    
    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    
    @Autowired
    public MyInvocationSecurityMetadataSourceService(TSysMenuDao tSysMenuDao,FilterStatic filterStatic) {
        this.tSysMenuDao = tSysMenuDao;
        patterns = new HashSet<>();
        //可通过配置过滤路径,这里就省略不写了,写法与AcmCasProperties一致
        for (String filter:filterStatic.getStaticFilters()){
           String regex= filter.replace("**","*").replace("*",".*");
           patterns.add(Pattern.compile(regex));
        }
    }



    /**
     * 查找url对应的角色
     */
    public  Collection<ConfigAttribute> loadResourceDefine(String url){
        Collection<ConfigAttribute> array=new ArrayList<>();
        ConfigAttribute cfg;
        SysMenu permission = tSysMenuDao.findMeneRoles(url);
        if (permission !=null) {
            for (String role :permission.getRoles().split(",")){
                cfg = new SecurityConfig(role);
                //此处只添加了用户的名字,其实还可以添加更多权限的信息,例如请求方法到ConfigAttribute的集合中去。此处添加的信息将会作为CasAccessDecisionManager类的decide的第三个参数。
                array.add(cfg);
            }
            return array;
        }
        return null;

    }

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        //object 中包含用户请求的request 信息
        HttpServletRequest request = ((FilterInvocation) object).getHttpRequest();
        String url = request.getRequestURI();
        url = url.replaceFirst(request.getContextPath(), "");
        logger.info(url);
        
        //将请求的url与配置文件中不需要访问控制的url进行匹配
        Iterator<Pattern> patternIterator=patterns.iterator();
        while (patternIterator.hasNext()){
            Pattern pattern = patternIterator.next();
            Matcher matcher=pattern.matcher(url);
            if (matcher.find())
                return null;
        }
        return loadResourceDefine(url);
    }


    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

AccessDecisionManager-授权处理器

承接上面的SecurityMetadataSource获取到的权限集合configAttributes,此处对此验证

@Component
public class CasAccessDecisionManager implements AccessDecisionManager {

	/**
	 * @param authentication 当前用户权限信息
	 * @param o 请求信息
	 * @param configAttributes 当前访问的url对应的角色
	 */
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        //没有角色要求则返回
    	if(null== configAttributes || configAttributes.size() <=0) {
            return;
        }
        //比较当前用户角色和当前访问的url对应的角色,是否拥有对应权限
        ConfigAttribute c;
        String needRole;
        for(Iterator<ConfigAttribute> iter = configAttributes.iterator(); iter.hasNext(); ) {
            c = iter.next();
            needRole = c.getAttribute();
            for(GrantedAuthority ga : authentication.getAuthorities()) {//authentication 为在注释1 中循环添加到 GrantedAuthority 对象中的权限信息集合
                if((SercurityConstants.prefix+needRole.trim()).equals(ga.getAuthority())) {
                    return;
                }
            }
        }
        throw new AccessDeniedException("no right");
    }

    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

总入口配置

主要是结合spring security进行相应的设置,因为CasAuthenticationFilter需要设置AuthenticationManager对象,所以放在总入口这里配置

@Configuration
@EnableWebSecurity
//如果依赖数据库读取角色等,则需要配置
@AutoConfigureAfter(MyBatisMapperScannerConfig.class)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
	/**
	 * 自定义动态权限过滤器
	 */
	@Resource
	private final CasFilterSecurityInterceptor myFilterSecurityInterceptor;
	
	@Resource
	private final FilterStatic filterStatic;

	/**
	 * 自定义过滤规则及其安全配置
	 */
	@Override
	protected void configure(HttpSecurity http) throws Exception {
		// HeadersConfigurer
		http.headers().frameOptions().disable();

		// CsrfConfigurer
		http.csrf().disable();

		// ExpressionInterceptUrlRegistry
		http.authorizeRequests().anyRequest().authenticated().anyRequest().fullyAuthenticated();

		// acm cas策略
		// 对logout请求放行
		http.logout().permitAll();
		// 入口
		CasAuthenticationEntryPoint entryPoint = getApplicationContext().getBean(CasAuthenticationEntryPoint.class);
		CasAuthenticationFilter casAuthenticationFilter = getApplicationContext()
					.getBean(CasAuthenticationFilter.class);
		SingleSignOutFilter singleSignOutFilter = getApplicationContext().getBean(SingleSignOutFilter.class);
		LogoutFilter logoutFilter = getApplicationContext().getBean(LogoutFilter.class);
			/**
			 * 执行顺序为
			 * LogoutFilter-->SingleSignOutFilter-->CasAuthenticationFilter-->
			 * ExceptionTranslationFilter
			 */
			http.exceptionHandling().authenticationEntryPoint(entryPoint).and().addFilter(casAuthenticationFilter)
					.addFilterBefore(logoutFilter, LogoutFilter.class)
					.addFilterBefore(singleSignOutFilter, CasAuthenticationFilter.class);
		} 
		// addFilter
	http.addFilterBefore(myFilterSecurityInterceptor, FilterSecurityInterceptor.class);
	}

	@Autowired
	public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
			//放入cas凭证校验器
			AuthenticationProvider authenticationProvider = (AuthenticationProvider) getApplicationContext()
					.getBean("casProvider");
			auth.authenticationProvider(authenticationProvider);

	}

	@Override
	public void configure(WebSecurity web) throws Exception {
		// 静态文静过滤
		String[] filter = filterStatic.getStaticFilters().toArray(new String[0]);
		web.ignoring().antMatchers(filter);
	}

	/**
	 * cas filter类
	 * 
	 * 针对/login请求的校验
	 * 
	 * @return
	 */
	@Bean
	public CasAuthenticationFilter casAuthenticationFilter(ServiceProperties properties,
			AcmCasProperties acmCasProperties) throws Exception {
		CasAuthenticationFilter casAuthenticationFilter = new CasAuthenticationFilter();
		casAuthenticationFilter.setServiceProperties(properties);
		casAuthenticationFilter.setFilterProcessesUrl(acmCasProperties.getAppServiceLoginUrl());
		casAuthenticationFilter.setAuthenticationManager(authenticationManager());
		casAuthenticationFilter
				.setAuthenticationSuccessHandler(new SimpleUrlAuthenticationSuccessHandler("/index.html"));
		return casAuthenticationFilter;
	}
}

Springboot启动类配置

@SpringBootApplication
@ComponentScan(basePackages = {"com.jingsir.springboot.cas"})
public class Application extends SpringBootServletInitializer implements EmbeddedServletContainerCustomizer {
    public static void main(String[] args) {
        SpringApplication.run(Application.class);
    }

    @Override
    public void customize(ConfigurableEmbeddedServletContainer configurableEmbeddedServletContainer) {
        configurableEmbeddedServletContainer.setContextPath("/cas-web");
    }
}

小结

当时对CasAuthenticationEntryPoint为何配置的service回调路径不可为本应用的login登录路径有疑惑,因为会被提前拦截显示"401错误"。分析wireshark的抓包后得知结论如下

  • 第一次用户GET请求到casServerLoginUrl,返回登录页面
  • 用户输入账号与密码后POST请求到casServerLoginUrl,其会返回TGC,并不返回ticket(所以此处不可为本应用的登录路径),由于FilterSecurityInterceptor校验仍失败,则仍会由ExceptionTranslationFilter发送GET请求转发至cas登录页面
  • 第二次用户GET请求到casServerLoginUrl,cas服务根据TGC会返回Ticket
  • 客户端拿到Ticket后会路由至cas服务上的/cas/serviceValidate上进行Ticket校验,校验通过后则访问真正的路径。且后面每次的请求都会携带Ticket去cas服务上校验,直至Ticket失效后则再次进行登录
    cas-login

本文都是通过实例操作后所写的博客,建议理解原理之后再可参照实例来编写,不当之处欢迎指出。

原文地址:https://www.cnblogs.com/question-sky/p/7068511.html