会话管理
7.1会话简介
当用户通过浏览器登录成功之后,用户和系统之间就会保持一个会话(session),通过这个会话,系统可以确定出访问用户的身份。Spring security中和会话相关的功能由SessionManagementFilter 和SessionAuthenticationStrategy 接口的组合来处理,过滤器委托该接口对会话进行处理,比较典型的用法有防止会话固定攻击、配置会话并发数等。
7.2会话并发管理
7.2.1实战
@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()
.maximumSessions(1)
.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);
}
@Bean
HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
}
7.2.2原理分析
SessionInformation
主要用作spring security框架内的会话记录:
public class SessionInformation implements Serializable {
private Date lastRequest;
private final Object principal;
private final String sessionId;
private boolean expired = false;
public void refreshLastRequest() {
this.lastRequest = new Date();
}
}
SessionRegistry
主要用来维护SessionInformation 实例。该接口只有一个实现类SessionRegistryImpl :
public class SessionRegistryImpl implements SessionRegistry, ApplicationListener<AbstractSessionEvent> {
private final ConcurrentMap<Object, Set<String>> principals;
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;
}
@Override
public void onApplicationEvent(AbstractSessionEvent event) {
if (event instanceof SessionDestroyedEvent) {
SessionDestroyedEvent sessionDestroyedEvent = (SessionDestroyedEvent) event;
String sessionId = sessionDestroyedEvent.getId();
removeSessionInformation(sessionId);
}
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());
}
@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;
}
@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) {
if (getSessionInformation(sessionId) != null) {
removeSessionInformation(sessionId);
}
this.sessionIds.put(sessionId, new SessionInformation(principal, sessionId, new Date()));
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);
this.principals.computeIfPresent(info.getPrincipal(), (key, sessionsUsedByPrincipal) -> {
sessionsUsedByPrincipal.remove(sessionId);
if (sessionsUsedByPrincipal.isEmpty()) {
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) {
int allowedSessions = getMaximumSessionsForThisUser(authentication);
if (allowedSessions == -1) {
return;
}
List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
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;
}
}
}
}
allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
}
protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions,
SessionRegistry registry) throws SessionAuthenticationException {
if (this.exceptionIfMaximumExceeded || (sessions == null)) {
throw new SessionAuthenticationException(
this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
new Object[] { allowableSessions }, "Maximum sessions of {0} for this principal exceeded"));
}
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) {
this.sessionRegistry.registerNewSession(request.getSession().getId(), authentication.getPrincipal());
}
CompositeSessionAuthenticationStrategy
相当于一个代理类,默认使用的其实就是该类的实例:
@Override
public void onAuthentication(Authentication authentication, HttpServletRequest request,
HttpServletResponse response) throws SessionAuthenticationException {
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);
if (!this.securityContextRepository.containsContext(request)) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
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);
}
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);
}
Session创建时机
在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();
if (securityContextRepository == null) {
if (stateless) {
http.setSharedObject(SecurityContextRepository.class, new NullSecurityContextRepository());
}
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) {
if (stateless) {
http.setSharedObject(RequestCache.class, new NullRequestCache());
}
}
http.setSharedObject(SessionAuthenticationStrategy.class, getSessionAuthenticationStrategy(http));
http.setSharedObject(InvalidSessionStrategy.class, getInvalidSessionStrategy());
}
@Override
public void configure(H http) {
SecurityContextRepository securityContextRepository = http.getSharedObject(SecurityContextRepository.class);
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);
if (isConcurrentSessionControlEnabled()) {
ConcurrentSessionFilter concurrentSessionFilter = createConcurrencyFilter(http);
concurrentSessionFilter = postProcess(concurrentSessionFilter);
http.addFilter(concurrentSessionFilter);
}
}
AbstractAuthenticationFilterConfigurer
所以,登录成功后,session并发管理到底是在哪里触发的。虽然经过前面的分析,知道有两个过滤器的存在:SessionManagementFilter 和ConcurrentSessionFilter ,但是前者在用户使用rememberMe认证时才会触发session并发管理,后者则根部不会触发session并发管理,这时可以回到AbstractAuthenticationProcessingFilter 的doFilter 方法中去看一下:
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
Authentication authenticationResult = attemptAuthentication(request, response);
if (authenticationResult == null) {
return;
}
this.sessionStrategy.onAuthentication(authenticationResult, request, response);
if (this.continueChainBeforeSuccessfulAuthentication) {
chain.doFilter(request, response);
}
successfulAuthentication(request, response, chain, authenticationResult);
}
}
@Override
public void configure(B http) throws Exception {
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 ,在该过滤器中,判断当前会话是否已经过期,如果过期就执行注销登录流程;如果没有过期,则更新最近一次请求时间。
7.3会话固定攻击与防御
7.3.1什么是会话固定攻击
会话固定攻击(session fixation attacks)是一种潜在的风险,恶意攻击者有可能通过访问当前应用程序来创建会话,然后诱导用户以相同的会话id登录(通常是将会话id作为参数放在请求链接中,然后诱导用户去单击),进而获取用户的登录身份。例如:
- 攻击者自己可以正常访问javaboy网站,在访问的过程中,网站给攻击者分配了一个sessionid。
- 攻击者利用自己拿到的sessionid构造一个javaboy网站的链接,并把该链接发送给受害者。
- 受害者使用该链接登录javaboy网站(该链接中含有sessionid),登录成功后,一个合法的会话就成功建立了。
- 攻击者利用手里的sessionid冒充受害者。
在这个过程中,如果javaboy网站支持URL重写,那么攻击还会变得更加容易。 用户如果在浏览器中禁用了cookie,那么sessionid自然也用不了,所以有的服务端就支持把sessionId放在请求地址中,例如http://www.javaboy.org;jsessionid=xxxxxx 。如果服务端支持这种URL重写,那么对于攻击者来说,按照上面的攻击流程,构造一个这样的地址会很容易。
7.3.2会话固定攻击防御策略
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 。
7.4Session共享
使用redis进行配置。
|