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

本文都是通過實例操作后所寫的博客,建議理解原理之后再可參照實例來編寫,不當之處歡迎指出。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM