SpringSecurity5(7-会话管理)
|总字数:3.8k|阅读时长:15分钟|浏览量:|
会话管理
http.sessionManagement()
- invalidSessionUrl(String invalidSessionUrl):指定会话失效时(请求携带无效的 JSESSIONID 访问系统)重定向的 URL,默认重定向到登录页面。
- invalidSessionStrategy(InvalidSessionStrategy invalidSessionStrategy):指定会话失效时(请求携带无效的 JSESSIONID 访问系统)的处理策略。
- maximumSessions(int maximumSessions):指定每个用户的最大并发会话数量,-1 表示不限数量。
- maxSessionsPreventsLogin(boolean maxSessionsPreventsLogin):如果设置为 true,表示某用户达到最大会话并发数后,新会话请求会被拒绝登录;如果设置为 false,表示某用户达到最大会话并发数后,新会话请求访问时,其最老会话会在下一次请求时失效并根据 expiredUrl() 或者 expiredSessionStrategy() 方法配置的会话失效策略进行处理,默认值为 false。
- expiredUrl(String expiredUrl):如果某用户达到最大会话并发数后,新会话请求访问时,其最老会话会在下一次请求时失效并重定向到 expiredUrl。
- expiredSessionStrategy(SessionInformationExpiredStrategy expiredSessionStrategy):如果某用户达到最大会话并发数后,新会话请求访问时,其最老会话会在下一次请求中失效并按照该策略处理请求。注意如果本方法与 expiredUrl() 同时使用,优先使用 expiredUrl() 的配置。
- sessionRegistry(SessionRegistry sessionRegistry):设置所要使用的 sessionRegistry,默认配置的是 SessionRegistryImpl 实现类
- sessionCreationPolicy:控制如何管理 Session
- SessionCreationPolicy.ALWAYS:总是创建 HttpSession
- SessionCreationPolicy.IF_REQUIRED:SpringSecurity 只会在需要时创建一个 HttpSession
- SessionCreationPolicy.NEVER:SpringSecurity 不会创建 HttpSession,但如果他已经存在,将可以使用 HttpSession
- SessionCreationPolicy.STATELESS:SpringSecurity 永远不会创建 HttpSession,他不会使用 HttpSession 来获取 SecurityContext
invalidSessionUrl
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Configuration public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() .and() .authorizeRequests() .anyRequest() .authenticated(); http.sessionManagement() .invalidSessionUrl("/login/page"); } }
|
1 2 3 4
| server.servlet.session.timeout=30m
server.servlet.session.cookie.max-age=-1
|
注意:Session 的失效时间至少要 1 分钟,少于 1 分钟按照 1 分钟配置

invalidSessionStrategy
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
|
@Component public class CustomInvalidSessionStrategy implements InvalidSessionStrategy {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override public void onInvalidSessionDetected(HttpServletRequest request, HttpServletResponse response) throws IOException { Cookie cookie = new Cookie("JSESSIONID", null); cookie.setPath(getCookiePath(request)); cookie.setMaxAge(0); response.addCookie(cookie);
String xRequestedWith = request.getHeader("x-requested-with"); if ("XMLHttpRequest".equals(xRequestedWith)) { response.setContentType("application/json;charset=utf-8"); response.getWriter().write("SESSION 失效,请重新登录!"); }else { redirectStrategy.sendRedirect(request, response, "/login/page"); } }
private String getCookiePath(HttpServletRequest request) { String contextPath = request.getContextPath(); return contextPath.length() > 0 ? contextPath : "/"; } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| @Configuration public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired private CustomInvalidSessionStrategy invalidSessionStrategy;
@Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() .and() .authorizeRequests() .anyRequest() .authenticated(); http.sessionManagement() .invalidSessionStrategy(invalidSessionStrategy); } }
|
会话并发控制
会话并发管理是指在当前系统中,同一个用户可以同时创建多少个会话,如果一台设备对应一个会话,也可以理解为同一个用户可以同时在多少个设备上进行登录。
在 Spring Security 中默认情况下,同一个用户在多少个设备上登录并没有限制,但是我们可以自己设置。
会话销毁监听
Spring Security 中通过一个 Map 集合来维护当前 HttpSession 记录,进而实现会话的并发管理,当用户登录成功后,就向集合添加一条 HttpSession 记录。
Map 的 key 是当前用户对象,value 是一个集合,这个集合中保存着这个用户的所有会话 session(这里存储的 session 是包装后的 session),每次登录后就能够去 Map 里面拿出来这个用户的所有会话然后判断一下(set)就知道该不该登录了,当用户注销登录的时候,用户的 session 会被自动销毁,但是 Map 中的 List 集合中的 session 并不会自动移除,所以就导致每次登录的时候都会判断为 session 已经登录,所以我们应当在用户注销登录的时候,将 list 集合中把用户对应的会话 session 移除掉
HttpSessionEventPublisher 实现了 HttpSessionListener 接口,可以监听到 HttpSession 的创建和销毁事件,并将 HttpSession 的创建和销毁事件发布出去,这样当有 HttpSession 销毁时,Spring Security 就可以感知到该事件了
1 2 3 4 5 6 7 8
|
@Bean public HttpSessionEventPublisher sessionEventPublisher() { return new HttpSessionEventPublisher(); }
|


Spring Security 是通过监听 HttpSession 对象的销毁事件来触发会话信息集合 principals 和 sessionIds 的清理工作,但是默认情况下是没有注册过相关的监听器,这会导致 Spring Security 无法正常清理过期或已注销的会话

限制用户二次登录
如果同一个用户在第二个地方登录,则不允许他二次登录
不建议使用该配置,因为用户一旦被盗号,那真正的用户后续就无法登录,只能通过联系管理员解决,所以如果只能一个用户 Session 登录,一般是新会话登录并将老会话踢下线
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| @Configuration public class SpringSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private CustomInvalidSessionStrategy invalidSessionStrategy;
@Override protected void configure(HttpSecurity http) throws Exception { http.formLogin() .and() .authorizeRequests() .anyRequest() .authenticated(); http.sessionManagement() .invalidSessionStrategy(invalidSessionStrategy) .maximumSessions(1) .maxSessionsPreventsLogin(true); .sessionRegistry(sessionRegistry()); }
@Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); }
@Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } }
|
踢人下线
如果同一个用户在第二个地方登录,则将第一个踢下线
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
|
@Component public class CustomSessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException { HttpServletRequest request = event.getRequest(); HttpServletResponse response = event.getResponse();
UserDetails userDetails = (UserDetails) event.getSessionInformation().getPrincipal(); String msg = String.format("用户[%s]在另外一台机器登录,您已下线!", userDetails.getUsername());
String xRequestedWith = event.getRequest().getHeader("x-requested-with"); if ("XMLHttpRequest".equals(xRequestedWith)) { response.setContentType("application/json;charset=utf-8"); response.getWriter().write(msg); }else { AuthenticationException e = new AuthenticationServiceException(msg); request.getSession().setAttribute("SPRING_SECURITY_LAST_EXCEPTION", e); redirectStrategy.sendRedirect(request, response, "/login/page?error"); } } }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| @Configuration public class SpringSecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired private CustomInvalidSessionStrategy invalidSessionStrategy; @Autowired private CustomSessionInformationExpiredStrategy sessionInformationExpiredStrategy;
@Override protected void configure(HttpSecurity http) throws Exception { http.sessionManagement() .invalidSessionStrategy(invalidSessionStrategy) .maximumSessions(1) .maxSessionsPreventsLogin(false) .sessionRegistry(sessionRegistry()) .expiredSessionStrategy(sessionInformationExpiredStrategy); }
@Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); }
@Bean public HttpSessionEventPublisher httpSessionEventPublisher() { return new HttpSessionEventPublisher(); } }
|
实现原理
- AbstractAuthenticationProcessingFilter 的 doFilter()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| public abstract class AbstractAuthenticationProcessingFilter extends GenericFilterBean implements ApplicationEventPublisherAware, MessageSourceAware { private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy(); public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest)req; HttpServletResponse response = (HttpServletResponse)res; if (!this.requiresAuthentication(request, response)) { chain.doFilter(request, response); } else { if (this.logger.isDebugEnabled()) { this.logger.debug("Request is to process authentication"); } Authentication authResult; try { authResult = this.attemptAuthentication(request, response); if (authResult == null) { return; } this.sessionStrategy.onAuthentication(authResult, request, response); } catch (InternalAuthenticationServiceException var8) { this.logger.error("An internal error occurred while trying to authenticate the user.", var8); this.unsuccessfulAuthentication(request, response, var8); return; } catch (AuthenticationException var9) { this.unsuccessfulAuthentication(request, response, var9); return; }
if (this.continueChainBeforeSuccessfulAuthentication) { chain.doFilter(request, response); } this.successfulAuthentication(request, response, chain, authResult); } } public void setSessionAuthenticationStrategy(SessionAuthenticationStrategy sessionStrategy) { this.sessionStrategy = sessionStrategy; } }
|
- CompositeSessionAuthenticationStrategy 的 onAuthentication()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| public class CompositeSessionAuthenticationStrategy implements SessionAuthenticationStrategy { private final List<SessionAuthenticationStrategy> delegateStrategies;
public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) throws SessionAuthenticationException { SessionAuthenticationStrategy delegate; for(Iterator var4 = this.delegateStrategies.iterator(); var4.hasNext(); delegate.onAuthentication(authentication, request, response)) { delegate = (SessionAuthenticationStrategy)var4.next(); if (this.logger.isDebugEnabled()) { this.logger.debug("Delegating to " + delegate); } } } }
|
- ConcurrentSessionControlAuthenticationStrategy 的 onAuthentication()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| public class ConcurrentSessionControlAuthenticationStrategy implements MessageSourceAware, SessionAuthenticationStrategy { public void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response) { List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false); int sessionCount = sessions.size(); int allowedSessions = this.getMaximumSessionsForThisUser(authentication); if (sessionCount >= allowedSessions) { if (allowedSessions != -1) { if (sessionCount == allowedSessions) HttpSession session = request.getSession(false); if (session != null) { Iterator var8 = sessions.iterator();
while(var8.hasNext()) { SessionInformation si = (SessionInformation)var8.next(); if (si.getSessionId().equals(session.getId())) { return; } } } } this.allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry); } } }
protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions, SessionRegistry registry) throws SessionAuthenticationException { if (!this.exceptionIfMaximumExceeded && sessions != null) { sessions.sort(Comparator.comparing(SessionInformation::getLastRequest)); int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1; List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy); Iterator var6 = sessionsToBeExpired.iterator();
while(var6.hasNext()) { SessionInformation session = (SessionInformation)var6.next(); session.expireNow(); } } else { throw new SessionAuthenticationException(this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed", new Object[]{allowableSessions}, "Maximum sessions of {0} for this principal exceeded")); } } }
|
其他
统计未过期的 Session 数量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Controller public class TestController { @Autowired private SessionRegistry sessionRegistry;
@GetMapping("/test4") @ResponseBody public Object getOnlineSession() { UserDetails user = (UserDetails) SecurityContextHolder.getContext().getAuthentication().getDetails(); List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(user, false); return new ResultData<>(sessions.size()); } }
|
统计所有在线用户
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| @Controller public class TestController { @Autowired private SessionRegistry sessionRegistry;
@GetMapping("/test5") @ResponseBody public Object getOnlineUsers() { List<String> userList = sessionRegistry.getAllPrincipals().stream() .map(user -> ((UserDetails) user).getUsername()) .collect(Collectors.toList()); return new ResultData<>(userList); } }
|
安全会话 Cookie
可以使用 httpOnly 和 secure 标签来保护我们的会话 cookie:
- httpOnly:如果为 true,那么浏览器脚本将无法访问 cookie
- secure:如果为 true,则 cookie 将仅通过 HTTPS 连接发送
1 2
| server.servlet.session.cookie.http‐only=true server.servlet.session.cookie.secure=true
|
会话集群控制
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> <version>2.8.0</version> </dependency>
<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| spring.redis.host=localhost
spring.redis.port=6379
spring.redis.password=
spring.redis.database=1
spring.redis.lettuce.pool.max-active=100
spring.redis.lettuce.pool.max-wait=PT10S
spring.redis.lettuce.pool.max-idle=10
spring.redis.lettuce.pool.min-idle=1
spring.redis.timeout=PT10S
spring.session.store-type=redis
server.servlet.session.cookie.name=JSESSIONID
|
Redis 存储 Session 默认的序列化方式为 JdkSerializationRedisSerializer,所以存入 Session 的对象都要实现 Serializable 接口