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类
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失效后则再次进行登录
本文都是通过实例操作后所写的博客,建议理解原理之后再可参照实例来编写,不当之处欢迎指出。
作者:南柯问天
出处:http://www.cnblogs.com/question-sky/
本文版权归本人和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。