SpringSecurity3.2.10 + SpringBoot2.1.11 + ConcurrentSession(分布式会话)+ redis
注意:SpringBoot2.1.11 应该搭配更高版本的SpringSecurity.
1、引入maven依赖
本项目中用的SpringBoot2.1.11,引入自带的 spring-boot-starter-security 版本为 5.1.7,但是由于是老项目需要兼容旧版本,所以使用了低版本的 3 个SpringSecurity包:
<dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-core</artifactId> <version>3.2.10.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-web</artifactId> <version>3.2.10.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> <version>3.2.10.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <!-- <version>1.5.20.RELEASE</version> --> <exclusions> <exclusion> <groupId>org.springframework.security</groupId> <artifactId>spring-security-core</artifactId> <!-- default version is 5.1.7 --> </exclusion> <exclusion> <groupId>org.springframework.security</groupId> <artifactId>spring-security-web</artifactId> </exclusion> <exclusion> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> </exclusion> </exclusions> </dependency>
当应用多节点分布式部署的时候,SpringSecurity本身是不能控制分布式会话的,所以需要第三方介质的介入,这里选择 redis,导入redis依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> <version>1.5.22.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> <!-- 注意,这个包必须引入,它可以支持SpringBoot的内置tomcat通过redis同步所有session --> </dependency>
2、自定义分布式会话控制类
这里先贴代码,后面再说怎么注入到SpringBoot 和SpringSecurity
import com.test.MyUser; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationListener; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.core.session.SessionDestroyedEvent; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; import org.springframework.stereotype.Component; import java.io.*; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.TimeUnit; @Component public class MySessionRegistryImpl implements SessionRegistry, ApplicationListener<SessionDestroyedEvent> { private static final Logger logger = LoggerFactory.getLogger(MySessionRegistryImpl.class); // 以下两个集合的代码作用,参考Spring原生实现类: org.springframework.security.core.session.SessionRegistryImpl /** <principal:Object,SessionIdSet> */ private final ConcurrentMap<Object, Set<String>> principals = new ConcurrentHashMap<Object,Set<String>>(); /** <sessionId:Object,SessionInformation> */ private final Map<String, SessionInformation> sessionIds = new ConcurrentHashMap<String, SessionInformation>(); @Autowired @Qualifier("redisTemplate") RedisTemplate redisTemplate; @Value("${session.timeout.minutes}") private Integer sessionTimeoutMinutes; @Override public void registerNewSession(String sessionId, Object principal) { MyUser myUser = (MyUser) principal; try { // put login user to local collection sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date())); logger.info("put login user to local collection success, username={}, sessionId={}", myUser.getUsername(), sessionId); // put login user to redis byte[] bytes = myUserToBytes(myUser); redisTemplate.opsForValue().set(sessionId, bytes, sessionTimeoutMinutes, TimeUnit.MINUTES); myUser.toString().getBytes(); logger.info("put login user to redis success, username={}, sessionId={}, bytes.length={}", myUser.getUsername(), sessionId, bytes.length); } catch (IOException e) { logger.error("register new sessionId[{}] to redis is fail, username={}", sessionId, myUser.getUsername(), e); } } /** * 这里参考kafka的优秀思想,存储数据都使用byte[]数组 * object to byte[] */ public byte[] myUserToBytes(MyUser myUser) throws IOException { try( ByteArrayOutputStream out = new ByteArrayOutputStream(); ObjectOutputStream sOut = new ObjectOutputStream(out); ){ sOut.writeObject(myUser); sOut.flush(); byte[] bytes = out.toByteArray(); return bytes; } } /** * byte[] to object */ public MyUser bytesToMyUser(byte[] bytes) throws IOException, ClassNotFoundException { try( ByteArrayInputStream in = new ByteArrayInputStream(bytes); ObjectInputStream sIn = new ObjectInputStream(in); ){ return (MyUser) sIn.readObject(); } } @Override public SessionInformation getSessionInformation(String sessionId) { // get login user from local collection , 优先从本地集合取值 SessionInformation sessionInformation = sessionIds.get(sessionId); if(null != sessionInformation){ MyUser myUser = (MyUser) sessionInformation.getPrincipal(); logger.info("get login user from local collection by sessionId success, username={}, sessionId={}", myUser.getUsername(), sessionId); return sessionInformation; } // get login user from redis Object sessionValue = redisTemplate.opsForValue().get(sessionId); if(null == sessionValue){ logger.info("can't find login user from redis by sessionId[{}]", sessionId); return null; } try { byte[] bytes = (byte[]) sessionValue; logger.info("get login user from redis by sessionId success, bytes.length={}", bytes.length); MyUser myUser = bytesToMyUser(bytes); logger.info("get login user from redis by sessionId success, username={}, sessionId={}, bytes.length={}", myUser.getUsername(), sessionId, bytes.length); SessionInformation sessionInfo = new SessionInformation(myUser, sessionId, new Date()); return sessionInfo; } catch (ClassNotFoundException | IOException e) { logger.error("get myUser from redis by session[{}] is fail", sessionId, e); } return null; } @Override public void removeSessionInformation(String sessionId) { boolean isDelete = redisTemplate.delete(sessionId); logger.info("remove sessionId from redis is sucess. isDelete={}", isDelete); } @Override public List<Object> getAllPrincipals() { return new ArrayList<Object>(principals.keySet()); } @Override public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) { final Set<String> sessionsUsedByPrincipal = principals.get(principal); if (sessionsUsedByPrincipal == null) { return Collections.emptyList(); } List<SessionInformation> list = new ArrayList<SessionInformation>(sessionsUsedByPrincipal.size()); for (String sessionId : sessionsUsedByPrincipal) { SessionInformation sessionInformation = getSessionInformation(sessionId); if (sessionInformation == null) { continue; } if (includeExpiredSessions || !sessionInformation.isExpired()) { list.add(sessionInformation); } } return list; } @Override public void onApplicationEvent(SessionDestroyedEvent event) { String sessionId = event.getId(); removeSessionInformation(sessionId); } @Override public void refreshLastRequest(String sessionId) { SessionInformation info = getSessionInformation(sessionId); if (info != null) { info.refreshLastRequest(); } } }
3、MySessionRegistryImpl 以SpringBoot的方式注入SpringSecurity
这部分由于我使用了低版本的SpringSecurity,所以参考spring官网地址是: https://docs.spring.io/spring-security/site/docs/3.1.x/reference/session-mgmt.html 《12. Session Management Prev Part III. Web Application Security》
SpringSecurity其他高版本的官方文档地址参考: https://docs.spring.io/spring-security/site/docs/
@Autowired MySessionRegistryImpl mySessionRegistryImpl; // 自定义分布式会话控制类 @Bean("concurrentSessionControlStrategy") public org.springframework.security.web.authentication.session.ConcurrentSessionControlStrategy getConcurrentSessionControlStrategy(){ return new ConcurrentSessionControlStrategy(mySessionRegistryImpl); // 注意,ConcurrentSessionControlStrategy 这个类在SpringSecurity 3.2.10 以上已过时 } @Bean("authenticationFilter") public MyAuthenticationFilter getMyAuthenticationFilter( @Qualifier("authenticationManager") ProviderManager authenticationManager ,@Qualifier("successHandler") MyAuthenticationSuccessHandler successHandler ,@Qualifier("failureHandler") MyAuthenticationFailureHandler failureHandler ,@Qualifier("concurrentSessionControlStrategy") ConcurrentSessionControlStrategy concurrentSessionControlStrategy ){ MyAuthenticationFilter filter = new MyAuthenticationFilter(); filter.setAuthenticationManager(authenticationManager); // spring原生类 org.springframework.security.authentication.ProviderManager filter.setAuthenticationSuccessHandler(successHandler); // 自定义类需 extends org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler filter.setAuthenticationFailureHandler(failureHandler); // 自定义类需 implements org.springframework.security.web.authentication.AuthenticationFailureHandler filter.setFilterProcessesUrl("/login/loginUser.xhtml"); filter.setAllowSessionCreation(false); // SpringSecurity + SpringBoot + ConcurrentSession + redis // refer doc from https://docs.spring.io/spring-security/site/docs/3.1.x/reference/session-mgmt.html filter.setSessionAuthenticationStrategy(concurrentSessionControlStrategy); // 重要!!!这里把自定义分布式session会话控制类加入SpringSecurity return filter; } @Bean("securityFilter") public FilterChainProxy getFilterChainProxy( @Qualifier("securityContextPersistenceFilter") SecurityContextPersistenceFilter securityContextPersistenceFilter ,@Qualifier("logoutFilter") LogoutFilter logoutFilter ,@Qualifier("authenticationFilter") MyAuthenticationFilter authenticationFilter ,@Qualifier("securityContextHolderAwareRequestFilter") SecurityContextHolderAwareRequestFilter securityContextHolderAwareRequestFilter ,@Qualifier("exceptionTranslationFilter") ExceptionTranslationFilter exceptionTranslationFilter ,@Qualifier("concurrentSessionFilter") ConcurrentSessionFilter concurrentSessionFilter ){ PrefixUriRequestMatcher requestMatcher = new PrefixUriRequestMatcher(); requestMatcher.setPrefixUris("/admin-portal/,/xxxx/"); SecurityFilterChain filterChain = new DefaultSecurityFilterChain( requestMatcher ,securityContextPersistenceFilter // spring原生类 org.springframework.security.web.context.SecurityContextPersistenceFilter ,logoutFilter // spring原生类 org.springframework.security.web.authentication.logout.LogoutFilter ,authenticationFilter // 自定义类需 extends org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter, 可选择性 implements org.springframework.context.ApplicationContextAware ,securityContextHolderAwareRequestFilter // spring原生类 org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter ,exceptionTranslationFilter // spring原生类 org.springframework.security.web.access.ExceptionTranslationFilter ,concurrentSessionFilter // 重要!!!只有这里加入自定义分布式session会话控制类,SpringSecurity才会执行这个Filter ); FilterChainProxy proxy = new FilterChainProxy(filterChain); return proxy; }
4、从Session中获取 login user,而不是从 SecurityContextHolder.getContext().getAuthentication() 这里获取登陆用户
感谢这位老兄: http://www.manongjc.com/article/97630.html 《SecurityContextHolder.getContext().getAuthentication()为null的情况》
原理描述:
因为无论你给SpringSecurity定义了多少个Filter过滤链,最后SpringSecurity都会执行 SecurityContextHolder.clearContext(); 而把SecurityContextHolder清空,所以会得到 SecurityContextHolder.getContext().getAuthentication() = null 的情况。
所以如果想获得当前用户,必须在spring security过滤器执行中执行获取login user的办法。
我的解决方案:
我这里选择在MySessionRegistryImpl 自定义分布式会话控制器里存储login user到redis。
再定义一个后台登陆过滤器 LogonFilter,在到达任何Controller请求之前,获取redis里的login user 放到session里,而spring容器因为引入了spring-session-data-redis依赖,已经通过redis将session同步到连接到此redis的所有SpringBoot节点。
5、获取request的小窍门
org.springframework.web.context.request.ServletRequestAttributes holder = (ServletRequestAttributes) org.springframework.web.context.request.RequestContextHolder; javax.servlet.http.HttpServletRequest request = holder.getRequest(); javax.servlet.http.HttpSession session = request.getSession();
end.