当用户通过浏览器登录成功之后,用户和系统之间就会保持一个会话(session),通过这个会话,系统可以确定出访问用户的身份。Spring security中和会话相关的功能由
SessionManagementFilter
和SessionAuthenticationStrategy
接口的组合来处理,过滤器委托该接口对会话进行处理,比较典型的用法有防止会话固定攻击、配置会话并发数等。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("javaboy")
.password("{noop}123")
.roles("admin");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().disable()
// 开启会话配置
.sessionManagement()
// 设置会话并发数为1,类似于挤下线的效果
.maximumSessions(1)
// 自定义会话销毁后的行为,重定向到/login页面
// .expiredUrl("/login")
// 如果是前后端分离的项目,就不需要页面跳转了,直接返回一段JSON提示即可
.expiredSessionStrategy(event -> {
HttpServletResponse response = event.getResponse();
response.setContentType("application/json;charset=utf-8");
Map<String, Object> result = new HashMap<>();
result.put("status", 500);
result.put("msg", "当前会话已经失效, 请重新登录");
response.getWriter().print(new ObjectMapper().writeValueAsString(result));
response.flushBuffer();
})
// 禁止后来者无法使用相同的用户登录,直到当前用户主动注销登录
.maxSessionsPreventsLogin(true);
}
/**
* 提供HttpSessionEventPublisher实例。Spring security中通过一个Map集合来维护当前的HttpSession记录,进而实现
* 会话的并发管理。当用户登录成功时,就向集合中添加一条HttpSession记录;当会话销毁时,就从集合中移除一条HttpSession
* 记录。HttpSessionEventPublisher实现了HttpSessionListener接口,可以监听到HttpSession的创建和销毁事件,并将
* HttpSession的创建/销毁事件发布出去,这样,当有HttpSession销毁时,spring security就可以感知到该事件了
*/
@Bean
HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
}
SessionInformation
主要用作spring security框架内的会话记录:
public class SessionInformation implements Serializable {
// 最近一次请求的时间
private Date lastRequest;
// 会话对应的主体(用户)
private final Object principal;
// 会话id
private final String sessionId;
// 会话是否过期
private boolean expired = false;
// 更新最近一次请求的时间
public void refreshLastRequest() {
this.lastRequest = new Date();
}
// 省略getter/setter
}
SessionRegistry
主要用来维护
SessionInformation
实例。该接口只有一个实现类SessionRegistryImpl
:
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> {
// ,保存当前登录主体(用户)和sessionId之间的关系,
// 由于用对象作为key,因此自定义用户类时需要重写equals和hashCode方法
private final ConcurrentMap<Object, Set<String>> principals;
// ,保存sessionId和SessionInformation之间的关系
private final Map<String, SessionInformation> sessionIds;
public SessionRegistryImpl() {
this.principals = new ConcurrentHashMap<>();
this.sessionIds = new ConcurrentHashMap<>();
}
public SessionRegistryImpl(ConcurrentMap<Object, Set<String>> principals,
Map<String, SessionInformation> sessionIds) {
this.principals = principals;
this.sessionIds = sessionIds;
}
// 重写ApplicationListener接口中的方法,接收HttpSession相关的事件
@Override
public void onApplicationEvent(AbstractSessionEvent event) {
// 如果是SessionDestroyedEvent事件,则移除掉HttpSession的记录
if (event instanceof SessionDestroyedEvent) {
SessionDestroyedEvent sessionDestroyedEvent = (SessionDestroyedEvent) event;
String sessionId = sessionDestroyedEvent.getId();
removeSessionInformation(sessionId);
}
// 如果是SessionIdChangedEvent事件,则更新HttpSession的记录
else if (event instanceof SessionIdChangedEvent) {
SessionIdChangedEvent sessionIdChangedEvent = (SessionIdChangedEvent) event;
String oldSessionId = sessionIdChangedEvent.getOldSessionId();
if (this.sessionIds.containsKey(oldSessionId)) {
Object principal = this.sessionIds.get(oldSessionId).getPrincipal();
removeSessionInformation(oldSessionId);
registerNewSession(sessionIdChangedEvent.getNewSessionId(), principal);
}
}
}
// 返回所有的用户登录对象
@Override
public List<Object> getAllPrincipals() {
return new ArrayList<>(this.principals.keySet());
}
// 返回某一个用户所对应的所有SessionInformation,第二个参数表示是否包含已经过期的session
@Override
public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
Set<String> sessionsUsedByPrincipal = this.principals.get(principal);
if (sessionsUsedByPrincipal == null) {
return Collections.emptyList();
}
List<SessionInformation> list = new ArrayList<>(sessionsUsedByPrincipal.size());
for (String sessionId : sessionsUsedByPrincipal) {
SessionInformation sessionInformation = getSessionInformation(sessionId);
if (sessionInformation == null) {
continue;
}
if (includeExpiredSessions || !sessionInformation.isExpired()) {
list.add(sessionInformation);
}
}
return list;
}
// 根据sessionId获取SessionInformation
@Override
public SessionInformation getSessionInformation(String sessionId) {
return this.sessionIds.get(sessionId);
}
@Override
public void refreshLastRequest(String sessionId) {
SessionInformation info = getSessionInformation(sessionId);
if (info != null) {
info.refreshLastRequest();
}
}
// 会话保存
@Override
public void registerNewSession(String sessionId, Object principal) {
// 如果sessionId存在,则先将其移除
if (getSessionInformation(sessionId) != null) {
removeSessionInformation(sessionId);
}
this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));
// 第一个参数是当前登录主体,第二个参数则进行计算。如果当前登录主体在principals中已经有对应的value,
// 则在value的基础上继续添加一个sessionId;如果没有对应的value,则新建一个sessionsUsedByPrincipal对象,
// 然后再将sessionId添加进去
this.principals.compute(principal, (key, sessionsUsedByPrincipal) -> {
if (sessionsUsedByPrincipal == null) {
sessionsUsedByPrincipal = new CopyOnWriteArraySet<>();
}
sessionsUsedByPrincipal.add(sessionId);
return sessionsUsedByPrincipal;
});
}
// 会话移除
@Override
public void removeSessionInformation(String sessionId) {
SessionInformation info = getSessionInformation(sessionId);
if (info == null) {
return;
}
this.sessionIds.remove(sessionId);
// 移除value中对应的sessionId
this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
sessionsUsedByPrincipal.remove(sessionId);
if (sessionsUsedByPrincipal.isEmpty()) {
// No need to keep object in principals Map anymore
sessionsUsedByPrincipal = null;
}
return sessionsUsedByPrincipal;
});
}
}
SessionAuthenticationStrategy
主要在用户登录成功后,对
HttpSession
进行处理:
public interface SessionAuthenticationStrategy {
void onAuthentication(Authentication authentication, HttpServletRequest request, HttpServletResponse response)
throws SessionAuthenticationException;
}
CsrfAuthenticationStrategy
:和CSRF攻击有关,该类主要负责在身份验证后删除旧的CsrfToken
并生成一个新的CsrfToken
。ConcurrentSessionControlAuthenticationStrategy
:该类主要用来处理session并发问题,例如并发数量控制就是通过该类来完成的。RegisterSessionAuthenticationStrategy
:该类用于在认证成功后将HttpSession
信息记录到SessionRegistry
中。CompositeSessionAuthenticationStrategy
:这是一个复合策略,它里边维护了一个集合,其中保存了多个不同的SessionAuthenticationStrategy
对象,相当于该类代理了多个SessionAuthenticationStrategy
对,大部分情况下,在spring security框架中直接使用的也是该类的实例。NullAuthenticatedSessionStrategy
:这是一个空的实现,未做任何处理。AbstractSessionFixationProtectionStrategy
:处理会话固定攻击的基类。ChangeSessionIdAuthenticationStrategy
:通过修改sessionId
来防止会话固定攻击。SessionFixationProtectionStrategy
:通过创建一个新的会话来防止会话固定攻击。
ConcurrentSessionControlAuthenticationStrategy
在前面的案例中,起主要作用的是
ConcurrentSessionControlAuthenticationStrategy
,因此先对该类进行重点分析:
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) {
// 获取session最大并发数,如果值为-1,则没有限制
int allowedSessions = getMaximumSessionsForThisUser(authentication);
if (allowedSessions == -1) {
return;
}
// 获取当前用户的所有未失效的SessionInformation实例
List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
// 如果获取到的SessionInformation实例数小于当前项目允许的最大session数,说明当前登录没问题,直接返回即可
int sessionCount = sessions.size();
if (sessionCount < allowedSessions) {
return;
}
if (sessionCount == allowedSessions) {
HttpSession session = request.getSession(false);
if (session != null) {
// 仅当此请求与已注册的会话之一关联时才允许
for (SessionInformation si : sessions) {
if (si.getSessionId().equals(session.getId())) {
return;
}
}
}
}
// 如果在前面的判断中没有return,说明当前用户登录的并发数已经超过允许的并发数了,
// 进入到allowableSessionsExceeded方法中进行处理
allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
}
protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions,
SessionRegistry registry) throws SessionAuthenticationException {
// exceptionIfMaximumExceeded的值由maxSeesionsPreventsLogin方法配置,即禁止后来者登录
if (this.exceptionIfMaximumExceeded || (sessions == null)) {
throw new SessionAuthenticationException(
this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
new Object[] { allowableSessions }, "Maximum sessions of {0} for this principal exceeded"));
}
// 按照最后一次请求的时间进行排序,计算出需要过期的session数量
sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
for (SessionInformation session : sessionsToBeExpired) {
session.expireNow();
}
}
RegisterSessionAuthenticationStrategy
该类的作用主要是向
SessionRegistry
中记录HttpSession
信息:
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) {
// 调用registerNewSession方法向sessionRegistry中添加一条登录会话信息
this.sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal());
}
CompositeSessionAuthenticationStrategy
相当于一个代理类,默认使用的其实就是该类的实例:
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) throws SessionAuthenticationException {
// 遍历维护的SessionAuthenticationStrategy集合,然后分别调用其onAuthentication方法
for (SessionAuthenticationStrategy delegate : this.delegateStrategies) {
delegate.onAuthentication(authentication, request, response);
}
}
SessionManagementFilter
和会话并发管理相关的过滤器主要有两个,先来看第一个
SessionManagementFilter
。
其主要用来处理remember-me登录时的会话管理:即如果用户使用了remember-me的方式进行认证,则认证成功后需要进行会话管理,相关的管理操作通过SessionManagementFilter
过滤器触发:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request.getAttribute(FILTER_APPLIED) != null) {
chain.doFilter(request, response);
return;
}
request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
// 判断当前会话中是否存在SPRING_SECURITY_CONTEXT_KEY变量。如果是正常的认证流程,则SPRING_SECURITY_CONTEXT_KEY变量
//是存在于当前会话中的。只有当用户使用了remember-me或者匿名访问某一个接口时,该变量才会不存在
if (!this.securityContextRepository.containsContext(request)) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 如果是通过remember-me的方式进行登录,则SecurityContextHolder中获取到的当前用户实例是RememberMeAuthenticationToken,
// 因此调用SessionAuthenticationStrategy中的onAuthentication方法进行会话管理
if (authentication != null && !this.trustResolver.isAnonymous(authentication)) {
try {
this.sessionAuthenticationStrategy.onAuthentication(authentication, request, response);
}
catch (SessionAuthenticationException ex) {
SecurityContextHolder.clearContext();
this.failureHandler.onAuthenticationFailure(request, response, ex);
return;
}
this.securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response);
}
// 如果是匿名登录,则当前用户实例是AnonymousAuthenticationToken,因此进行会话失效处理
else {
if (request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid()) {
if (this.invalidSessionStrategy != null) {
this.invalidSessionStrategy.onInvalidSessionDetected(request, response);
return;
}
}
}
}
chain.doFilter(request, response);
}
ConcurrentSessionFilter
处理会话并发管理的过滤器。
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpSession session = request.getSession(false);
if (session != null) {
SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId());
if (info != null) {
if (info.isExpired()) {
doLogout(request, response);
// 调用会话过期的回调
this.sessionInformationExpiredStrategy
.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
return;
}
// 未过期 - 更新最后一次请求时间
this.sessionRegistry.refreshLastRequest(info.getSessionId());
}
}
chain.doFilter(request, response);
}
在spring security中,
HttpSession
的创建策略一共分为四种:
ALWAYS
:如果HttpSession
不存在,就创建。NEVER
:从不创建,但是如果已经存在了,则会使用它。IF_REQUIRED
:当有需要时,会创建,默认即此。STATELESS
:从不创建,也不使用。需要注意的是,这四种策略仅仅是指spring security中的创建策略,而并非整个应用程序的。第四种适合于无状态的认证方式,意味着服务端不会创建
HttpSession
,客户端的每一个请求都需要携带认证信息,同时,一些和HttpSession
相关的过滤器也将失效,例如SessionManagementFilter
、ConcurrentSessionFilter
等。
如果需要的话,可以自行配置:
@Override
protected void configure(HttpSecurity http) throws Exception {
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED);
}
SessionManagementConfigurer
SessionManagementConfigurer
配置类完成了上面两个过滤器的配置:
@Override
public void init(H http) {
SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
boolean stateless = isStateless();
// 如果没有获取到SecurityContextRepository的实例,则进行创建,分为两种情况
if (securityContextRepository == null) {
// 如果创建策略是STATELESS,则使用NullSecurityContextRepository,相当于不保存
if (stateless) {
http.setSharedObject(SecurityContextRepository.class, new NullSecurityContextRepository());
}
// 否则构建HttpSessionSecurityContextRepository的实例,并最终存入HttpSecurity的共享对象中以备使用
else {
HttpSessionSecurityContextRepository httpSecurityRepository = new HttpSessionSecurityContextRepository();
httpSecurityRepository.setDisableUrlRewriting(!this.enableSessionUrlRewriting);
httpSecurityRepository.setAllowSessionCreation(isAllowSessionCreation());
AuthenticationTrustResolver trustResolver = http.getSharedObject(AuthenticationTrustResolver.class);
if (trustResolver != null) {
httpSecurityRepository.setTrustResolver(trustResolver);
}
http.setSharedObject(SecurityContextRepository.class, httpSecurityRepository);
}
}
RequestCache requestCache = http.getSharedObject(RequestCache.class);
if (requestCache == null) {
// 如果创建策略是STATELESS,还需要将保存在HttpSecurity共享对象中的请求缓存对象替换为NullRequestCache的实例
if (stateless) {
http.setSharedObject(RequestCache.class, new NullRequestCache());
}
}
// 构建三个SessionAuthenticationStrategy的实例,分别是ConcurrentSessionControlAuthenticationStrategy、
// ChangeSessionIdAuthenticationStrategy、RegisterSessionAuthenticationStrategy,并将这三个实例
// 由CompositeSessionAuthenticationStrategy进行代理
http.setSharedObject(SessionAuthenticationStrategy.class, getSessionAuthenticationStrategy(http));
// 构建InvalidSessionStrategy实例
http.setSharedObject(InvalidSessionStrategy.class, getInvalidSessionStrategy());
}
// 主要是构建了两个过滤器SessionManagementFilter和ConcurrentSessionFilter
@Override
public void configure(H http) {
SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
// 通过getSessionAuthenticationStrategy方法获取SessionAuthenticationStrategy实例
// 并传入SessionManagementFilter实例中
SessionManagementFilter sessionManagementFilter = new SessionManagementFilter(securityContextRepository,
getSessionAuthenticationStrategy(http));
if (this.sessionAuthenticationErrorUrl != null) {
sessionManagementFilter.setAuthenticationFailureHandler(
new SimpleUrlAuthenticationFailureHandler(this.sessionAuthenticationErrorUrl));
}
InvalidSessionStrategy strategy = getInvalidSessionStrategy();
if (strategy != null) {
sessionManagementFilter.setInvalidSessionStrategy(strategy);
}
AuthenticationFailureHandler failureHandler = getSessionAuthenticationFailureHandler();
if (failureHandler != null) {
sessionManagementFilter.setAuthenticationFailureHandler(failureHandler);
}
AuthenticationTrustResolver trustResolver = http.getSharedObject(AuthenticationTrustResolver.class);
if (trustResolver != null) {
sessionManagementFilter.setTrustResolver(trustResolver);
}
sessionManagementFilter = postProcess(sessionManagementFilter);
http.addFilter(sessionManagementFilter);
// 如果配置了会话并发控制(只要调用了maximumSessions()方法配置了会话最大并发数,就算开启了会话并发控制),
// 就再创建一个ConcurrentSessionFilter过滤器并加入HttpSecurity中
if (isConcurrentSessionControlEnabled()) {
ConcurrentSessionFilter concurrentSessionFilter = createConcurrencyFilter(http);
concurrentSessionFilter = postProcess(concurrentSessionFilter);
http.addFilter(concurrentSessionFilter);
}
}
AbstractAuthenticationFilterConfigurer
所以,登录成功后,session并发管理到底是在哪里触发的。虽然经过前面的分析,知道有两个过滤器的存在:
SessionManagementFilter
和ConcurrentSessionFilter
,但是前者在用户使用rememberMe认证时才会触发session并发管理,后者则根部不会触发session并发管理,这时可以回到AbstractAuthenticationProcessingFilter
的doFilter
方法中去看一下:
// AbstractAuthenticationProcessingFilter
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
// 省略
try {
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
// return immediately as subclass has indicated that it hasn't completed
return;
}
// 在这个地方触发了session的并发管理,这里的sessionStrategy对象则是在
// AbstractAuthenticationFilterConfigurer类的configure方法中进行配置的
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
// Authentication success
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult);
}
// 省略
}
// AbstractAuthenticationFilterConfigurer
@Override
public void configure(B http) throws Exception {
// 省略
// 从HttpSecurity的共享对象中获取到SessionAuthenticationStrategy实例,
// 并设置到authFilter过滤器中
SessionAuthenticationStrategy sessionAuthenticationStrategy = http
.getSharedObject(SessionAuthenticationStrategy.class);
if (sessionAuthenticationStrategy != null) {
this.authFilter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
}
// 省略
}
大致的流程分析:
- 用户通过用户名/密码发起一个认证请求,当认证成功后,在
AbstractAuthenticationProcessingFilter#doFilter
方法中触发了session并发管理。- 默认的
sessionStrategy
是CompositeSessionAuthenticationStrategy
,它一共代理了三个SessionAuthenticationStrategy
,分别是ConcurrentSessionControlAuthenticationStrategy
、ChangeSessionIdAuthenticationStrategy
以及RegisterSessionAuthenticationStrategy
。- 当前请求在这三个中分别走一圈,第一个用来判断用户的session数是否已经超出限制,如果超出限制就根据配置好的规则作出处理;第二个用来修改
sessionId
(以防止会话固定攻击);第三个用来将当前session注册到SessionRegistry
中。- 使用用户名/密码的方式完成认证,将不会涉及
ConcurrentSessionFilter
和SessionManagementFilter
两个过滤器。- 如果用户使用了remember-me的方式来进行身份认证,则会通过
SessionManagementFilter#doFilter
方法触发session并发管理。当用户认证成功后,以后的每一次请求都会经过ConcurrentSessionFilter
,在该过滤器中,判断当前会话是否已经过期,如果过期就执行注销登录流程;如果没有过期,则更新最近一次请求时间。
会话固定攻击(session fixation attacks)是一种潜在的风险,恶意攻击者有可能通过访问当前应用程序来创建会话,然后诱导用户以相同的会话id登录(通常是将会话id作为参数放在请求链接中,然后诱导用户去单击),进而获取用户的登录身份。例如:
- 攻击者自己可以正常访问javaboy网站,在访问的过程中,网站给攻击者分配了一个sessionid。
- 攻击者利用自己拿到的sessionid构造一个javaboy网站的链接,并把该链接发送给受害者。
- 受害者使用该链接登录javaboy网站(该链接中含有sessionid),登录成功后,一个合法的会话就成功建立了。
- 攻击者利用手里的sessionid冒充受害者。
在这个过程中,如果javaboy网站支持URL重写,那么攻击还会变得更加容易。
用户如果在浏览器中禁用了cookie,那么sessionid自然也用不了,所以有的服务端就支持把sessionId放在请求地址中,例如http://www.javaboy.org;jsessionid=xxxxxx
。如果服务端支持这种URL重写,那么对于攻击者来说,按照上面的攻击流程,构造一个这样的地址会很容易。
Spring security从三个方面入手防范会话固定攻击:
- Spring security中默认自带了HTTP防火墙,如果sessionid放在地址栏中,这个请求就会直接被拦截下来。
- 在HTTP相应的Set-Cookie字段中有httpOnly属性,这样避免了通过XSS攻击来获取cookie中的会话信息,进而达成会话固定攻击。
- 既然会话固定攻击是由于sessionid不变导致的,那么其中一个解决办法就是在用户登录成功后,改变sessionid,spring security中默认实现了该种方案,实现类是
ChangeSessionIdAuthenticationStrategy
。前两种都是默认行为,第三种方案,spring security中有几种不同的配置策略:
http.sessionManagement().sessionFixation().changeSessionId();
通过
sessionFixation()
方法开启会话固定攻击防御的配置,一共有四种不同的策略,不同的策略对应了不同的SessionAuthenticationStrategy
:
changeSessionId()
:用户登录成功后,直接修改HttpSession
的sessionId
即可,默认方案即此,对应的处理类是ChangeSessionIdAuthenticationStrategy
。none()
:用户登录成功后,HttpSession
不做任何变化,对应的处理类是NullAuthenticatedSessionStrategy
。migrateSession()
:用户登录成功后,创建一个新的HttpSession
对象,并将旧的HttpSession
中的数据拷贝到新的中,对应的处理类是SessionFixationProtectionStrategy
。newSession()
:用户登录成功后,创建一个新的HttpSession
对象,对应的处理类也是SessionFixationProtectionStrategy
,只不过将其里边的migrateSessionAttributes
属性设置为false
。需要注意的是,该方法并非所有的属性都不拷贝,一些spring security使用的属性,如请求缓存,还是会从旧的HttpSession
复制到新的HttpSession
。这四种策略,无论使用哪种,都相当于配置了一个
SessionAuthenticationStrategy
,由于默认使用的是ChangeSessionIdAuthenticationStrategy
,如果开发者在这里配置了其他类型的SessionAuthenticationStrategy
,就会替代掉默认使用的ChangeSessionIdAuthenticationStrategy
。
前面所讲的会话管理都是单机上的会话管理,如果当前是集群环境,前面所讲的方案就会失效。需要注意的是,这里讨论的范畴是有状态登录,如果采用无状态的认证方案,那么就不涉及会话,也就不存在接下来要讨论的问题。
如果项目是集群化部署,可以采用nginx做反向代理服务器,所有到达nginx上的请求都被转发到不同的tomcat实例上,每个tomcat各自保存自己的会话信息。Spring security中通过维护一张会话注册表来实现会话的并发管理,现在每个tomcat上都有一张会话注册表,所以如果还按照之前的方式去配置会话并发管理,那必然是不生效的。
为了解决集群环境下的会话问题,有三种方案:
- Session复制:多个服务之间互相复制session信息,这样每个服务中都包含所有的session信息了,tomcat通过IP组播对这种方案提供支持。但是这种方案占用带宽、有时延,服务数量越多效率越低,所以这种方案使用较少。
- Session粘滞:也叫会话保持,就是在nginx上通过一致性hash,将hash结果相同的请求总是分发到一个服务上去。这种方案可以解决一部分集群会话带来的问题,但是无法解决集群中的会话并发管理问题。
- Session共享:session共享就是将不同服务的会话统一放在一个地方,所有的服务共享一个会话。一般使用一些key-value数据库来存储session,例如memcached或者redis等,比较常见的方案是使用redis存储,session共享方案由于其简便性与稳定性,是目前使用较多的方案。
Session共享目前使用比较多的是spring-session,利用spring-session可以方便地实现session的管理。
引入web、redis、spring security以及spring session的相关依赖:
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-securityartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.sessiongroupId>
<artifactId>spring-session-data-redisartifactId>
dependency>
接下来在
application.properties
中配置redis的连接信息(根据自己redis服务器的情况进行配置):
spring.redis.password=123
spring.redis.host=127.0.0.1
spring.redis.port=6379
之后提供一个
SecurityConfig
:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 注入FindByIndexNameSessionRepository对象,这是一个会话的存储和加载工具,具体的实现类是RedisIndexedSessionRepository
@Autowired
FindByIndexNameSessionRepository sessionRepository;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("javaboy")
.password("{noop}123")
.roles("admin");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.and()
.csrf().disable()
.sessionManagement()
// 配置session并发数量为1
.maximumSessions(1)
// 配置sessionRegistry
.sessionRegistry(sessionRegistry());
}
// 配置SpringSessionBackedSessionRegistry实例,其继承自SessionRegistry,用来维护会话信息注册表
@Bean
SpringSessionBackedSessionRegistry sessionRegistry() {
return new SpringSessionBackedSessionRegistry(sessionRepository);
}
}
需要注意的是,引入spring-session之后,不再需要配置
HttpSessionEventPublisher
实例,因为spring-session中通过SessionRepositoryFilter
将请求对象重新封装为SessionRepositoryRequestWrapper
,并重写了getSession
方法。在重写的getSession
方法中,最终返回的是HttpSessionWrapper
实例,而在HttpSessionWrapper
定义时,就重写了invalidate
方法。当调用会话的invalidate
方法去销毁会话时,就会调用RedisIndexedSessionRepository
中的方法,从redis中移除对应的会话信息,所以不再需要HttpSessionEventPublisher
实例。
最后再配置一个测试Controller
:
@RestController
public class HelloController {
@GetMapping("/")
public String hello(HttpSession session) {
// 返回HttpSession的类型以验证
return session.getClass().toString();
}
}
配置完成后进行打包操作:
需要注意的是,为了方便测试,最好打包成jar文件,方便启动两个实例:
java -jar part7_4-0.0.1-SNAPSHOT.jar --server.port=8083
java -jar part7_4-0.0.1-SNAPSHOT.jar --server.port=8084
两个实例启动完成后,这两个实例实际上共用了一个会话。