前言:
之前项目使用springmvc开发的cas client,由于以后新项目需要改用springboot开发,所以需要使用springboot来实现cas的单点登录、并完成对自定义需求的实现;之前使用shiro-cas,官方在1.3版本已经标注了过时,根据推荐使用了pac4j-cas来实现;
文章目录
默认的shiroSessionStore中getTrackableSession返回直接返回null,导致收到cas服务端发过来的单点登出请求后不会清除对应st创建的session,无法登出;所以自定义CustomShiroSessionStore,实现了默认为null的方法,重启后可以成功登出;
@Override
public Object getTrackableSession(J2EContext context) {
return getSession(true);
}
@Override
public SessionStore buildFromTrackableSession(
J2EContext context, Object trackableSession) {
if(trackableSession != null) {
return new ShiroProvidedSessionStore((Session) trackableSession);
}
return null;
}
根据需求,有一下页面可以允许未登录访问,但是当用户在其他项目中已经登录的后,打开这些页面也需要能够显示用户信息。因此shiro默认的 anon过滤器就无法满足该需求;这边自定义了一个CustomCasFilter,在doFilter中检查cookie中是否包含了tgc,如果已经包含了,就去做登录流程;否则不处理( 这边能获取到tgc是因为已经把cas服务端写cookie的域改成了一级域名,而该项目的域名与cas服务端同属于同一一级域名)
@Override
public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain filterChain) throws IOException, ServletException {
assertNotNull("securityLogic", securityLogic);
assertNotNull("config", config);
final HttpServletRequest request = (HttpServletRequest) servletRequest;
final HttpServletResponse response = (HttpServletResponse) servletResponse;
final SessionStore sessionStore = config.getSessionStore();
final J2EContext context = new J2EContext(request, response, sessionStore != null ? sessionStore : ShiroSessionStore.INSTANCE);
if(!SecurityUtils.getSubject().isAuthenticated()){
Collection cookies = context.getRequestCookies();
Optional fid = cookies.stream().filter(cookie -> "fid".equals(cookie.getName())).findFirst(); //我们tgc存储的name就是fid
if(fid.isPresent()&& !StringUtils.isEmpty(fid.get().getValue())) {
//在其他项目中已经登录、跳去登录验证;
securityLogic.perform(context, config, (ctx, profiles, parameters) -> {
filterChain.doFilter(request, response);
return null;
}, J2ENopHttpActionAdapter.INSTANCE, clients, authorizers, matchers, multiProfile);
}
}
// 不登录也能访问的页面
filterChain.doFilter(request, response);
}
因为每次使用用户信息都需要2次npe判断,所以使用CustomContextThreadLocalFilter过滤器来减少重复代码
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
try {
Subject subject = SecurityUtils.getSubject();
PrincipalCollection pcs = subject.getPrincipals();
if(null !=pcs){
Pac4jPrincipal p = pcs.oneByType(Pac4jPrincipal.class);
ContextHolder.setPac4jPrincipal(p);
}
filterChain.doFilter(servletRequest, servletResponse);
} finally {
ContextHolder.clear();
}
}
package com.spean.shiro_cas.util;
import io.buji.pac4j.subject.Pac4jPrincipal;
/**
* 线程内提供 Pac4jPrincipal 访问
* @author ssss
*
*/
public class ContextHolder {
private static final ThreadLocal threadLocal = new ThreadLocal();
public static void setPac4jPrincipal(final Pac4jPrincipal pac4jPrincipal) {
threadLocal.set(pac4jPrincipal);
}
public static Pac4jPrincipal getPac4jPrincipal() {
return threadLocal.get();
}
public static void clear() {
threadLocal.set(null);
}
}
手动登录出后需要指定跳转地址、默认接收的指定参数名为"url",正则校验位 /.*;这边修改正则校验,扩大url的范围
// 注销 拦截器
LogoutFilter logoutFilter = new LogoutFilter();
logoutFilter.setConfig(config);
logoutFilter.setCentralLogout(true);
logoutFilter.setLocalLogout(true);
//添加logout后 跳转到指定url url的匹配规则 默认为 /.*;
logoutFilter.setLogoutUrlPattern(".*");
logoutFilter.setDefaultUrl(projectUrl + "/callback?client_name=" + clientName);
filters.put("logout",logoutFilter);
引入依赖
org.apache.shiro
shiro-spring
${shiro.version}
io.buji
buji-pac4j
${buji.version}
org.pac4j
pac4j-cas
${pac4j.version}
创建Pac4jConfig
package com.spean.shiro_cas.config.shiro;
import org.pac4j.cas.client.CasClient;
import org.pac4j.cas.config.CasConfiguration;
import org.pac4j.cas.config.CasProtocol;
import org.pac4j.cas.logout.DefaultCasLogoutHandler;
import org.pac4j.core.config.Config;
import org.pac4j.core.context.WebContext;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class Pac4jConfig {
/** 地址为:cas地址 */
@Value("${cas.server.url}")
private String casServerUrl;
/** 地址为:验证返回后的项目地址:http://localhost:8081 */
@Value("${cas.project.url}")
private String projectUrl;
/** 相当于一个标志,可以随意 */
@Value("${cas.client-name}")
private String clientName;
/**
* pac4j配置
* @param casClient
* @param shiroSessionStore
* @return
*/
@Bean()
public Config config(CasClient casClient, CustomShiroSessionStore shiroSessionStore) {
Config config = new Config(casClient);
config.setSessionStore(shiroSessionStore);
return config;
}
/**
* 自定义存储
* @return
*/
@Bean
public CustomShiroSessionStore shiroSessionStore(){
return CustomShiroSessionStore.INSTANCE;
}
/**
* cas 客户端配置
* @param casConfig
* @return
*/
@Bean
public CasClient casClient(CasConfiguration casConfig){
CasClient casClient = new CustomCasClient(casConfig);
//客户端回调地址
casClient.setCallbackUrl(projectUrl + "/callback?client_name=" + clientName);
casClient.setName(clientName);
return casClient;
}
/**
* 请求cas服务端配置
* @param casLogoutHandler
*/
@Bean
public CasConfiguration casConfig(){
final CasConfiguration configuration = new CasConfiguration();
//CAS server登录地址
configuration.setLoginUrl(casServerUrl + "/login");
//CAS 版本,默认为 CAS30,我们使用的是 CAS20
configuration.setProtocol(CasProtocol.CAS20);
configuration.setAcceptAnyProxy(true);
configuration.setPrefixUrl(casServerUrl + "/");
configuration.setLogoutHandler(new DefaultCasLogoutHandler());
return configuration;
}
}
创建ShiroConfig
package com.spean.shiro_cas.config.shiro;
import io.buji.pac4j.filter.CallbackFilter;
import io.buji.pac4j.filter.LogoutFilter;
import io.buji.pac4j.filter.SecurityFilter;
import io.buji.pac4j.subject.Pac4jSubjectFactory;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import javax.servlet.DispatcherType;
import javax.servlet.Filter;
import org.apache.shiro.session.mgt.SessionManager;
import org.apache.shiro.session.mgt.eis.MemorySessionDAO;
import org.apache.shiro.session.mgt.eis.SessionDAO;
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.apache.shiro.web.servlet.SimpleCookie;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.pac4j.core.config.Config;
import org.springframework.aop.framework.autoproxy.DefaultAdvisorAutoProxyCreator;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.core.Ordered;
import org.springframework.web.filter.DelegatingFilterProxy;
@Configuration
public class ShiroConfig {
/** 项目工程路径 */
@Value("${cas.project.url}")
private String projectUrl;
/** 项目cas服务路径 */
@Value("${cas.server.url}")
private String casServerUrl;
/** 客户端名称 */
@Value("${cas.client-name}")
private String clientName;
@Bean("securityManager")
public DefaultWebSecurityManager securityManager(Pac4jSubjectFactory subjectFactory, SessionManager sessionManager, CasRealm casRealm){
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
manager.setRealm(casRealm);
manager.setSubjectFactory(subjectFactory);
manager.setSessionManager(sessionManager);
return manager;
}
@Bean
public CasRealm casRealm(){
CasRealm realm = new CasRealm();
// 使用自定义的realm
realm.setClientName(clientName);
realm.setCachingEnabled(false);
//暂时不使用缓存
realm.setAuthenticationCachingEnabled(false);
realm.setAuthorizationCachingEnabled(false);
//realm.setAuthenticationCacheName("authenticationCache");
//realm.setAuthorizationCacheName("authorizationCache");
return realm;
}
/**
* 使用 pac4j 的 subjectFactory
* @return
*/
@Bean
public Pac4jSubjectFactory subjectFactory(){
return new Pac4jSubjectFactory();
}
@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("/*");
filterRegistration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD);
return filterRegistration;
}
/**
* 加载shiroFilter权限控制规则(从数据库读取然后配置)
* @param shiroFilterFactoryBean
*/
private void loadShiroFilterChain(ShiroFilterFactoryBean shiroFilterFactoryBean){
/*下面这些规则配置最好配置到配置文件中 */
Map filterChainDefinitionMap = new LinkedHashMap<>();
filterChainDefinitionMap.put("/", "securityFilter");
filterChainDefinitionMap.put("/application/**", "securityFilter");
filterChainDefinitionMap.put("/index", "securityFilter");
filterChainDefinitionMap.put("/hello", "securityFilter");
filterChainDefinitionMap.put("/userInfo", "customCasFilter");
filterChainDefinitionMap.put("/callback", "callbackFilter");
filterChainDefinitionMap.put("/logout", "logout");
filterChainDefinitionMap.put("/**","anon");
// filterChainDefinitionMap.put("/user/edit/**", "authc,perms[user:edit]");
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
}
/**
* shiroFilter
* @param securityManager
* @param config
* @return
*/
@Bean("shiroFilter")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager, Config config) {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
// 必须设置 SecurityManager
shiroFilterFactoryBean.setSecurityManager(securityManager);
//shiroFilterFactoryBean.setUnauthorizedUrl("/403");
// 添加casFilter到shiroFilter中
loadShiroFilterChain(shiroFilterFactoryBean);
Map filters = new HashMap<>(4);
//cas 资源认证拦截器
SecurityFilter securityFilter = new SecurityFilter();
securityFilter.setConfig(config);
securityFilter.setClients(clientName);
filters.put("securityFilter", securityFilter);
//cas 自定义资源认证拦截器--允许未登录返回,但是如果在其他项目中已经登录的(cookie中已经包含了tgc)又需要他能够显示用户信息
CustomCasFilter customCasFilter = new CustomCasFilter();
customCasFilter.setConfig(config);
customCasFilter.setClients(clientName);
filters.put("customCasFilter", customCasFilter);
//cas 认证后回调拦截器
CallbackFilter callbackFilter = new CustomCallbackFilter();
callbackFilter.setConfig(config);
callbackFilter.setDefaultUrl(projectUrl);
filters.put("callbackFilter", callbackFilter);
// 注销 拦截器
LogoutFilter logoutFilter = new LogoutFilter();
logoutFilter.setConfig(config);
logoutFilter.setCentralLogout(true);
logoutFilter.setLocalLogout(true);
//添加logout后 跳转到指定url url的匹配规则 默认为 /.*;
logoutFilter.setLogoutUrlPattern(".*");
logoutFilter.setDefaultUrl(projectUrl + "/callback?client_name=" + clientName);
filters.put("logout",logoutFilter);
shiroFilterFactoryBean.setFilters(filters);
return shiroFilterFactoryBean;
}
@Bean
public SessionDAO sessionDAO(){
return new MemorySessionDAO();
}
/**
* 自定义cookie名称
* @return
*/
@Bean
public SimpleCookie sessionIdCookie(){
SimpleCookie cookie = new SimpleCookie("sid");
cookie.setMaxAge(-1);
cookie.setPath("/");
cookie.setHttpOnly(false);
return cookie;
}
@Bean
public DefaultWebSessionManager sessionManager(SimpleCookie sessionIdCookie, SessionDAO sessionDAO){
DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();
sessionManager.setSessionIdCookie(sessionIdCookie);
sessionManager.setSessionIdCookieEnabled(true);
//30分钟
sessionManager.setGlobalSessionTimeout(180000);
sessionManager.setSessionDAO(sessionDAO);
sessionManager.setDeleteInvalidSessions(true);
sessionManager.setSessionValidationSchedulerEnabled(true);
return sessionManager;
}
/**
* 下面的代码是添加注解支持
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
// https://zhuanlan.zhihu.com/p/29161098
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public static LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
@Bean
public FilterRegistrationBean casAssertionThreadLocalFilter(ShiroFilterFactoryBean shiroFilterFactoryBean) {
/**
* 所有经过身份过滤拦截的请求、都需要经过CustomAssertionThreadLocalFilter 这个过滤器、
*/
Map filterChainDefinitionMap = shiroFilterFactoryBean.getFilterChainDefinitionMap();
List casUrls = new LinkedList();
for (Entry entry : filterChainDefinitionMap.entrySet()) {
if("securityFilter".equals(entry.getValue())||"customCasFilter".equals(entry.getValue())){
casUrls.add(entry.getKey());
}
}
final FilterRegistrationBean assertionTLFilter = new FilterRegistrationBean();
assertionTLFilter.setFilter(new CustomContextThreadLocalFilter());
assertionTLFilter.setOrder(Ordered.LOWEST_PRECEDENCE);
assertionTLFilter.setUrlPatterns(casUrls);
return assertionTLFilter;
}
}
创建自定义扩展类
package com.spean.shiro_cas.config.shiro;
import io.buji.pac4j.context.ShiroSessionStore;
import org.apache.shiro.session.Session;
public class ShiroProvidedSessionStore extends ShiroSessionStore {
/**存储的TrackableSession,往后要操作时用这个session操作*/
private Session session;
public ShiroProvidedSessionStore(Session session) {
this.session = session;
}
@Override
protected Session getSession(final boolean createSession) {
return session;
}
}
package com.spean.shiro_cas.config.shiro;
import io.buji.pac4j.context.ShiroSessionStore;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.UnavailableSecurityManagerException;
import org.apache.shiro.session.Session;
import org.pac4j.core.context.J2EContext;
import org.pac4j.core.context.session.SessionStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CustomShiroSessionStore implements SessionStore {
private final static Logger logger = LoggerFactory.getLogger(ShiroSessionStore.class);
public final static CustomShiroSessionStore INSTANCE = new CustomShiroSessionStore();
/**
* Get the Shiro session (do not create it if it does not exist).
*
* @param createSession create a session if requested
* @return the Shiro session
*/
protected Session getSession(final boolean createSession) {
return SecurityUtils.getSubject().getSession(createSession);
}
@Override
public String getOrCreateSessionId(final J2EContext context) {
final Session session = getSession(false);
if (session != null) {
return session.getId().toString();
}
return null;
}
@Override
public Object get(final J2EContext context, final String key) {
final Session session = getSession(false);
if (session != null) {
return session.getAttribute(key);
}
return null;
}
@Override
public void set(final J2EContext context, final String key, final Object value) {
final Session session = getSession(true);
if (session != null) {
try {
session.setAttribute(key, value);
} catch (final UnavailableSecurityManagerException e) {
logger.warn("Should happen just once at startup in some specific case of Shiro Spring configuration", e);
}
}
}
@Override
public boolean destroySession(final J2EContext context) {
getSession(true).stop();
return true;
}
@Override
public Object getTrackableSession(J2EContext context) {
return getSession(true);
}
@Override
public SessionStore buildFromTrackableSession(
J2EContext context, Object trackableSession) {
if(trackableSession != null) {
return new ShiroProvidedSessionStore((Session) trackableSession);
}
return null;
}
@Override
public boolean renewSession(J2EContext context) {
return false;
}
}
package com.spean.shiro_cas.config.shiro;
import org.pac4j.cas.client.CasClient;
import org.pac4j.cas.config.CasConfiguration;
import org.pac4j.core.context.Pac4jConstants;
import org.pac4j.core.context.WebContext;
import org.pac4j.core.context.session.SessionStore;
import org.pac4j.core.redirect.RedirectAction;
import org.pac4j.core.util.CommonHelper;
public class CustomCasClient extends CasClient {
public CustomCasClient() {
super();
}
public CustomCasClient(CasConfiguration configuration) {
super(configuration);
}
@Override
public RedirectAction getRedirectAction(WebContext context) {
this.init();
if (getAjaxRequestResolver().isAjax(context)) {
this.logger.info("AJAX request detected -> returning the appropriate action");
RedirectAction action = getRedirectActionBuilder().redirect(context);
this.cleanRequestedUrl(context);
return getAjaxRequestResolver().buildAjaxResponse(action.getLocation(), context);
} else {
final String attemptedAuth = (String)context.getSessionStore().get(context, this.getName() + ATTEMPTED_AUTHENTICATION_SUFFIX);
if (CommonHelper.isNotBlank(attemptedAuth)) {
this.cleanAttemptedAuthentication(context);
this.cleanRequestedUrl(context);
//这里按自己需求处理,默认是返回了401,我在这边改为跳转到cas登录页面
//throw HttpAction.unauthorized(context);
return this.getRedirectActionBuilder().redirect(context);
} else {
return this.getRedirectActionBuilder().redirect(context);
}
}
}
private void cleanRequestedUrl(WebContext context) {
SessionStore sessionStore = context.getSessionStore();
if (sessionStore.get(context, Pac4jConstants.REQUESTED_URL) != null) {
sessionStore.set(context, Pac4jConstants.REQUESTED_URL, "");
}
}
private void cleanAttemptedAuthentication(WebContext context) {
SessionStore sessionStore = context.getSessionStore();
if (sessionStore.get(context, this.getName() + ATTEMPTED_AUTHENTICATION_SUFFIX) != null) {
sessionStore.set(context, this.getName() + ATTEMPTED_AUTHENTICATION_SUFFIX, "");
}
}
}
package com.spean.shiro_cas.config.shiro;
import io.buji.pac4j.realm.Pac4jRealm;
import io.buji.pac4j.subject.Pac4jPrincipal;
import io.buji.pac4j.token.Pac4jToken;
import java.util.List;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authz.AuthorizationInfo;
import org.apache.shiro.authz.SimpleAuthorizationInfo;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.SimplePrincipalCollection;
import org.pac4j.core.profile.CommonProfile;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CasRealm extends Pac4jRealm {
Logger logger = LoggerFactory.getLogger(CasRealm.class);
private String clientName;
public String getClientName() {
return clientName;
}
public void setClientName(String clientName) {
this.clientName = clientName;
}
/**
* 认证
*
* @param authenticationToken
* @return
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(
AuthenticationToken authenticationToken)
throws AuthenticationException {
final Pac4jToken pac4jToken = (Pac4jToken) authenticationToken;
final List commonProfileList = pac4jToken.getProfiles();
final CommonProfile commonProfile = commonProfileList.get(0);
logger.info("单点登录返回的信息" + commonProfile.toString());
final Pac4jPrincipal principal = new Pac4jPrincipal(commonProfileList,
getPrincipalNameAttribute());
final PrincipalCollection principalCollection = new SimplePrincipalCollection(
principal, getName());
return new SimpleAuthenticationInfo(principalCollection,
commonProfileList.hashCode());
}
/**
* 授权/验权(todo 后续有权限在此增加)
*
* @param principals
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(
PrincipalCollection principals) {
SimpleAuthorizationInfo authInfo = new SimpleAuthorizationInfo();
authInfo.addStringPermission("user");
return authInfo;
}
}
package com.spean.shiro_cas.config.shiro;
import io.buji.pac4j.filter.CallbackFilter;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
/**
* sso登录回调 返回st,单点登录也会post到这个地址,
* 验证st后跳转
* http://localhost/callback?client_name=demoClient&ticket=ST-54-AKN6qLpOlwlMgjtP22Yf-sso.foxitreader.cn
* @author ssss
*
*/
public class CustomCallbackFilter extends CallbackFilter {
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
super.doFilter(servletRequest, servletResponse, filterChain);
}
}