承接前文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類
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 #本應用退出地址
- 對應的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;
}
}
- 其中用到了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注解來完成,包括LogoutFilter、SingleSignOutFilter、ticket校驗器、service配置對象、cas憑證校驗器Provider、CasAuthenticationEntryPoint-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失效后則再次進行登錄

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