SpringSecurity登录介绍

SpringSecurity 简介

SpringSecurity是一个框架,提供身份验证、授权和针对常见攻击的保护。由于对命令式应用程序和反应式应用程序的一流支持,它是保护基于Spring的应用程序的事实上的标准。

SpringSecurity常用过滤器介绍

常用的过滤器有15个,分别如下:

1.org.springframework.security.web.context.SecurityContextPersistenceFilter

首当其冲的一个过滤器,非常重要 主要是使用SecurityContextRepository在session中保存或更新一个SecurityContext,并将SecurityContext给以后的过滤器使用,来为后续filter建立所需的上下文,SecurityContext中存储了当前用户的认证和权限信息。

2.org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter

此过滤器用于继承SecurityContext到Spring异步执行机制中的WebAsyncManager,和spring整合必须的。

3.org.springframework.security.web.header.HeaderWriterFilter

向请求的header中添加响应的信息,可以在http标签内部使用security:headers来控制

4.org.springframework.security.web.csrf.CsrfFilter

Csrf又称跨域请求伪造,SpringSecurity会对所有post请求验证是否包含系统生成的csrf的token信息,如果不包含则报错,起到防止csrf攻击的效果

5.org.springframework.security.web.authentication.logout.LogoutFilter

匹配URL为/logout的请求,实现用户退出,清楚认证信息

6.org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter

认证操作全靠这个过滤器,默认匹配URL为/login且必须为POST请求

7.org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter

如果没有在配置文件中指定认证页面,则由该过滤器生成一个默认的认证界面

8.org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter

由此过滤器生成一个默认的退出登录页面

9.org.springframework.security.web.authentication.www.BasicAuthenticationFilter

此过滤器会自动解析HTTP请求中头部名字为Authentication,且以Basic开头的头部信息

10.org.springframework.security.web.savedrequest.RequestCacheAwareFilter

通过HttpSessionRequestCache内部维护一个RequestCache,用于缓存HttpServletRequest

11.org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter

针对ServletRequest进行一次包装,使得request具有更加丰富的API

12.org.springframework.security.web.authentication.AnonymousAuthenticationFilter

当SecurityContextHolder中认证信息为空,则会创建一个匿名用户存储到SecurityContextHolder中,SpringSecurity为了兼容未登录的访问,也走了一套认证流程,只不过是一个匿名的身份

13.org.springframework.security.web.session.SessionManagementFilter

SecurityContextRepository限制同一个用户开启多个会话的数量

14.org.springframework.security.web.access.ExceptionTranslationFilter

异常转换过滤器位于整个SpringSecurityFilterChain的后方,用来转换整个链路中出现的异常

15.org.springframework.security.web.access.intercept.FilterSecurityInterceptor

获取所有配置资源的访问授权信息,根据SecurityContextHolder中存储的用户信息来决定其是否有权限。

过滤器加载过程

org.springframework.web.filter.DelegatingFilterProxy

	// org.springframework.web.filter.DelegatingFilterProxy#doFilter
	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
			throws ServletException, IOException {

		// Lazily initialize the delegate if necessary.
		Filter delegateToUse = this.delegate;
		if (delegateToUse == null) {
			synchronized (this.delegateMonitor) {
				delegateToUse = this.delegate;
				if (delegateToUse == null) {
					WebApplicationContext wac = findWebApplicationContext();
					if (wac == null) {
						throw new IllegalStateException("No WebApplicationContext found: " +
								"no ContextLoaderListener or DispatcherServlet registered?");
					}
                    // 初始化过滤器链
					delegateToUse = initDelegate(wac);
				}
				this.delegate = delegateToUse;
			}
		}

		// Let the delegate perform the actual doFilter operation.
		invokeDelegate(delegateToUse, request, response, filterChain);
	}
	// org.springframework.web.filter.DelegatingFilterProxy#initDelegate
	protected Filter initDelegate(WebApplicationContext wac) throws ServletException {
        // targetBeanName 实际上就是 securityFilterChain
		String targetBeanName = getTargetBeanName();
		Assert.state(targetBeanName != null, "No target bean name set");
        // 获取的实际类型是 FilterChainProxy
		Filter delegate = wac.getBean(targetBeanName, Filter.class);
		if (isTargetFilterLifecycle()) {
			delegate.init(getFilterConfig());
		}
		return delegate;
	}

org.springframework.security.web.FilterChainProxy

通过上面的源码分析我们发现其实创建的是FilterChainProxy这个过滤器,那我们来看下这个过滤器。

public class FilterChainProxy extends GenericFilterBean {

	private static final Log logger = LogFactory.getLog(FilterChainProxy.class);

	private static final String FILTER_APPLIED = FilterChainProxy.class.getName().concat(".APPLIED");
	
    // 存储的有关过滤器链中的的相关过滤器
	private List<SecurityFilterChain> filterChains;

	private FilterChainValidator filterChainValidator = new NullFilterChainValidator();

	private HttpFirewall firewall = new StrictHttpFirewall();

	private RequestRejectedHandler requestRejectedHandler = new DefaultRequestRejectedHandler();

因为这个是一个过滤器,使用我们看一下它的doFilter方法

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws IOException, ServletException {
   boolean clearContext = request.getAttribute(FILTER_APPLIED) == null;
   if (!clearContext) {
      // 真正添加的方法,接下来就看一下这个方法
      doFilterInternal(request, response, chain);
      return;
   }
   try {
      request.setAttribute(FILTER_APPLIED, Boolean.TRUE);
      // 真正添加的方法,接下来就看一下这个方法
      doFilterInternal(request, response, chain);
   }
   catch (RequestRejectedException ex) {
      this.requestRejectedHandler.handle((HttpServletRequest) request, (HttpServletResponse) response, ex);
   }
   finally {
      SecurityContextHolder.clearContext();
      request.removeAttribute(FILTER_APPLIED);
   }
}

进入doFilterInternal方法,然后debug查看

    private void doFilterInternal(ServletRequest request, ServletResponse response, FilterChain chain)
          throws IOException, ServletException {
       FirewalledRequest firewallRequest = this.firewall.getFirewalledRequest((HttpServletRequest) request);
       HttpServletResponse firewallResponse = this.firewall.getFirewalledResponse((HttpServletResponse) response);
       // 获取到了15个过滤器,接下来看一下这个过滤器怎么来的
       List<Filter> filters = getFilters(firewallRequest);
       if (filters == null || filters.size() == 0) {
          if (logger.isTraceEnabled()) {
             logger.trace(LogMessage.of(() -> "No security for " + requestLine(firewallRequest)));
          }
          firewallRequest.reset();
          chain.doFilter(firewallRequest, firewallResponse);
          return;
       }
       if (logger.isDebugEnabled()) {
          logger.debug(LogMessage.of(() -> "Securing " + requestLine(firewallRequest)));
       }
       VirtualFilterChain virtualFilterChain = new VirtualFilterChain(firewallRequest, chain, filters);
       virtualFilterChain.doFilter(firewallRequest, firewallResponse);
    }

我们看到这15个过滤器被保存到了List容器中了。 对应的 this.getFilters方法如下:

	private List<Filter> getFilters(HttpServletRequest request) {
		int count = 0;
		for (SecurityFilterChain chain : this.filterChains) {
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Trying to match request against %s (%d/%d)", chain, ++count,
						this.filterChains.size()));
			}
			if (chain.matches(request)) {
                // 真正的获取过滤器 org.springframework.security.web.DefaultSecurityFilterChain#getFilters
				return chain.getFilters();
			}
		}
		return null;
	}

org.springframework.security.web.SecurityFilterChain

public interface SecurityFilterChain {

	boolean matches(HttpServletRequest request);

	List<Filter> getFilters();

}

org.springframework.security.web.DefaultSecurityFilterChain SecurityFilterChain的实现

public final class DefaultSecurityFilterChain implements SecurityFilterChain {

	private static final Log logger = LogFactory.getLog(DefaultSecurityFilterChain.class);

	private final RequestMatcher requestMatcher;
	
    // 存储的过滤器
	private final List<Filter> filters;

	public DefaultSecurityFilterChain(RequestMatcher requestMatcher, Filter... filters) {
		this(requestMatcher, Arrays.asList(filters));
	}

	public DefaultSecurityFilterChain(RequestMatcher requestMatcher, List<Filter> filters) {
		logger.info(LogMessage.format("Will secure %s with %s", requestMatcher, filters));
		this.requestMatcher = requestMatcher;
        // 初始化的时候加载过滤器
		this.filters = new ArrayList<>(filters);
	}

	public RequestMatcher getRequestMatcher() {
		return this.requestMatcher;
	}

	@Override
	public List<Filter> getFilters() {
		return this.filters;
	}

	@Override
	public boolean matches(HttpServletRequest request) {
		return this.requestMatcher.matches(request);
	}

	@Override
	public String toString() {
		return this.getClass().getSimpleName() + " [RequestMatcher=" + this.requestMatcher + ", Filters=" + this.filters
				+ "]";
	}

}

总结:

通过上面的代码分析,SpringSecurity中要使用到的过滤器最终都保存在了DefaultSecurityFilterChain对象的List filter对象中。

SpringSecurity 登录流程

1、无处不在的Authentication

玩过 Spring Security 的小伙伴都知道,在 Spring Security 中有一个非常重要的对象叫做 Authentication,我们可以在任何地方注入 Authentication 进而获取到当前登录用户信息,Authentication 本身是一个接口,它有很多实现类:

img

在这众多的实现类中,我们最常用的就是 UsernamePasswordAuthenticationToken 了,但是当我们打开这个类的源码后,却发现这个类平平无奇,他只有两个属性、两个构造方法以及若干个 get/set 方法;当然,他还有更多属性在它的父类上。

但是从它仅有的这两个属性中,我们也能大致看出,这个类就保存了我们登录用户的基本信息。那么我们的登录信息是如何存到这两个对象中的?这就要来梳理一下登录流程了。

2、登录流程

在 Spring Security 中,认证与授权的相关校验都是在一系列的过滤器链中完成的,在这一系列的过滤器链中,和认证相关的过滤器就是 UsernamePasswordAuthenticationFilter

public class UsernamePasswordAuthenticationFilter extends
		AbstractAuthenticationProcessingFilter {
    public static final String SPRING_SECURITY_FORM_USERNAME_KEY = "username";

	public static final String SPRING_SECURITY_FORM_PASSWORD_KEY = "password";

	private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER = new AntPathRequestMatcher("/login",
			"POST");
	// 默认的表单提交 用户名 name值
	private String usernameParameter = SPRING_SECURITY_FORM_USERNAME_KEY;
	// 默认的表单提交 密码 name值
	private String passwordParameter = SPRING_SECURITY_FORM_PASSWORD_KEY;
	// 是否只能是PSOT方式提交
	private boolean postOnly = true;
	public UsernamePasswordAuthenticationFilter() {
		super(new AntPathRequestMatcher("/login", "POST"));
	}
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
			throws AuthenticationException {
		if (this.postOnly && !request.getMethod().equals("POST")) {
			throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
		}
		String username = obtainUsername(request);
		username = (username != null) ? username : "";
		username = username.trim();
		String password = obtainPassword(request);
		password = (password != null) ? password : "";
		UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
		// Allow subclasses to set the "details" property
		setDetails(request, authRequest);
		return this.getAuthenticationManager().authenticate(authRequest);
	}
	protected String obtainPassword(HttpServletRequest request) {
		return request.getParameter(this.passwordParameter);
	}
	protected String obtainUsername(HttpServletRequest request) {
		return request.getParameter(usernameParameter);
	}
	protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
		authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
	}
}

根据这段源码我们可以看出:

  1. 21行,判断当前请求是否是POST方式提交,前提是 “postOnly” 为 true
  2. 24行与27行 首先通过 obtainUsername 和 obtainPassword 方法提取出请求里边的用户名/密码出来,提取方式就是 request.getParameter ,这也是为什么 Spring Security 中默认的表单登录要通过 key/value 的形式传递参数,而不能传递 JSON 参数,如果像传递 JSON 参数,修改这里的逻辑即可。
  3. 获取到请求里传递来的用户名/密码之后,接下来就构造一个 UsernamePasswordAuthenticationToken 对象,传入 username 和 password,username 对应了 UsernamePasswordAuthenticationToken 中的 principal 属性,而 password 则对应了它的 credentials 属性。
  4. 接下来 setDetails 方法给 details 属性赋值,UsernamePasswordAuthenticationToken 本身是没有 details 属性的,这个属性在它的父类 AbstractAuthenticationToken 中。details 是一个对象,这个对象里边放的是 WebAuthenticationDetails 实例,该实例主要描述了两个信息,请求的 remoteAddress 以及请求的 sessionId
  5. 最后一步,就是调用 authenticate 方法去做校验了。

好了,从这段源码中,大家可以看出来请求的各种信息基本上都找到了自己的位置,找到了位置,这就方便我们未来去获取了。

接下来我们再来看请求的具体校验操作。

在前面的 attemptAuthentication 方法中,该方法的最后一步开始做校验,校验操作首先要获取到一个 AuthenticationManager,这里拿到的是 ProviderManager ,所以接下来我们就进入到 ProviderManager 的 authenticate 方法中.

	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Class<? extends Authentication> toTest = authentication.getClass();
		AuthenticationException lastException = null;
		AuthenticationException parentException = null;
		Authentication result = null;
		Authentication parentResult = null;
		int currentPosition = 0;
		int size = this.providers.size();
		for (AuthenticationProvider provider : getProviders()) {
			if (!provider.supports(toTest)) {
				continue;
			}
			if (logger.isTraceEnabled()) {
				logger.trace(LogMessage.format("Authenticating request with %s (%d/%d)",
						provider.getClass().getSimpleName(), ++currentPosition, size));
			}
			try {
				result = provider.authenticate(authentication);
				if (result != null) {
					copyDetails(authentication, result);
					break;
				}
			}
			catch (AccountStatusException | InternalAuthenticationServiceException ex) {
				prepareException(ex, authentication);
				// SEC-546: Avoid polling additional providers if auth failure is due to
				// invalid account status
				throw ex;
			}
			catch (AuthenticationException ex) {
				lastException = ex;
			}
		}
		if (result == null && this.parent != null) {
			// Allow the parent to try.
			try {
				parentResult = this.parent.authenticate(authentication);
				result = parentResult;
			}
			catch (ProviderNotFoundException ex) {
				// ignore as we will throw below if no other exception occurred prior to
				// calling parent and the parent
				// may throw ProviderNotFound even though a provider in the child already
				// handled the request
			}
			catch (AuthenticationException ex) {
				parentException = ex;
				lastException = ex;
			}
		}
		if (result != null) {
			if (this.eraseCredentialsAfterAuthentication && (result instanceof CredentialsContainer)) {
				// Authentication is complete. Remove credentials and other secret data
				// from authentication
				((CredentialsContainer) result).eraseCredentials();
			}
			// If the parent AuthenticationManager was attempted and successful then it
			// will publish an AuthenticationSuccessEvent
			// This check prevents a duplicate AuthenticationSuccessEvent if the parent
			// AuthenticationManager already published it
			if (parentResult == null) {
				this.eventPublisher.publishAuthenticationSuccess(result);
			}

			return result;
		}

		// Parent was null, or didn't authenticate (or throw an exception).
		if (lastException == null) {
			lastException = new ProviderNotFoundException(this.messages.getMessage("ProviderManager.providerNotFound",
					new Object[] { toTest.getName() }, "No AuthenticationProvider found for {0}"));
		}
		// If the parent AuthenticationManager was attempted and failed then it will
		// publish an AbstractAuthenticationFailureEvent
		// This check prevents a duplicate AbstractAuthenticationFailureEvent if the
		// parent AuthenticationManager already published it
		if (parentException == null) {
			prepareException(lastException, authentication);
		}
		throw lastException;
	}

这个方法就比较魔幻了,因为几乎关于认证的重要逻辑都将在这里完成:

  1. 首先获取 authentication 的 Class,判断当前 provider 是否支持该 authentication。
  2. 如果支持,则调用 provider 的 authenticate 方法开始做校验,校验完成后,会返回一个新的 Authentication。一会来和大家捋这个方法的具体逻辑。
  3. 这里的 provider 可能有多个,如果 provider 的 authenticate 方法没能正常返回一个 Authentication,则调用 provider 的 parent 的 authenticate 方法继续校验。
  4. copyDetails 方法则用来把旧的 Token 的 details 属性拷贝到新的 Token 中来。
  5. 接下来会调用 eraseCredentials 方法擦除凭证信息,也就是你的密码,这个擦除方法比较简单,就是将 Token 中的 credentials 属性置空。
  6. 最后通过 publishAuthenticationSuccess 方法将登录成功的事件广播出去。

大致的流程,就是上面这样,在 for 循环中,第一次拿到的 provider 是一个 AnonymousAuthenticationProvider,这个 provider 压根就不支持 UsernamePasswordAuthenticationToken,也就是会直接在 provider.supports 方法中返回 false,结束 for 循环,然后会进入到下一个 if 中,直接调用 parent 的 authenticate 方法进行校验。

而 parent 就是 ProviderManager,所以会再次回到这个 authenticate 方法中。再次回到 authenticate 方法中,provider 也变成了 DaoAuthenticationProvider,这个 provider 是支持 UsernamePasswordAuthenticationToken 的,所以会顺利进入到该类的 authenticate 方法去执行,而 DaoAuthenticationProvider 继承自 AbstractUserDetailsAuthenticationProvider 并且没有重写 authenticate 方法,所以 我们最终来到 AbstractUserDetailsAuthenticationProvider#authenticate 方法中:

	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
		Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication,
				() -> this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.onlySupports",
						"Only UsernamePasswordAuthenticationToken is supported"));
        // return (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName();
		String username = determineUsername(authentication);
		boolean cacheWasUsed = true;
		UserDetails user = this.userCache.getUserFromCache(username);
		if (user == null) {
			cacheWasUsed = false;
			try {
				user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
			}
			catch (UsernameNotFoundException ex) {
				this.logger.debug("Failed to find user '" + username + "'");
				if (!this.hideUserNotFoundExceptions) {
					throw ex;
				}
				throw new BadCredentialsException(this.messages
						.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
			}
			Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract");
		}
		try {
			this.preAuthenticationChecks.check(user);
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
		}
		catch (AuthenticationException ex) {
			if (!cacheWasUsed) {
				throw ex;
			}
			// There was a problem, so try again after checking
			// we're using latest data (i.e. not from the cache)
			cacheWasUsed = false;
			user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication);
			this.preAuthenticationChecks.check(user);
			additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication);
		}
		this.postAuthenticationChecks.check(user);
		if (!cacheWasUsed) {
			this.userCache.putUserInCache(user);
		}
		Object principalToReturn = user;
		if (this.forcePrincipalAsString) {
			principalToReturn = user.getUsername();
		}
		return createSuccessAuthentication(principalToReturn, authentication, user);
	}

这里的逻辑就比较简单了:

  1. 首先从 Authentication 提取出登录用户名。
  2. 然后通过拿着 username 去调用 retrieveUser 方法去获取当前用户对象,这一步会调用我们自己在登录时候的写的 loadUserByUsername 方法,所以这里返回的 user 其实就是你的登录对象
  3. 接下来调用 preAuthenticationChecks.check 方法去检验 user 中的各个账户状态属性是否正常,例如账户是否被禁用、账户是否被锁定、账户是否过期等等。
  4. additionalAuthenticationChecks 方法则是做密码比对的,好多小伙伴好奇 Spring Security 的密码加密之后,是如何进行比较的,看这里就懂了,因为比较的逻辑很简单,我这里就不贴代码出来了。
  5. 最后在 postAuthenticationChecks.check 方法中检查密码是否过期。
  6. 接下来有一个 forcePrincipalAsString 属性,这个是是否强制将 Authentication 中的 principal 属性设置为字符串,这个属性我们一开始在 UsernamePasswordAuthenticationFilter 类中其实就是设置为字符串的(即 username),但是默认情况下,当用户登录成功之后, 这个属性的值就变成当前用户这个对象了。之所以会这样,就是因为 forcePrincipalAsString 默认为 false,不过这块其实不用改,就用 false,这样在后期获取当前用户信息的时候反而方便很多。
  7. 最后,通过 createSuccessAuthentication 方法构建一个新的 UsernamePasswordAuthenticationToken。

好了,那么登录的校验流程现在就基本和大家捋了一遍了。那么接下来还有一个问题,登录的用户信息我们去哪里查找?

3、用户信息保存

要去找登录的用户信息,我们得先来解决一个问题,就是上面我们说了这么多,这一切是从哪里开始被触发的?

我们来到 UsernamePasswordAuthenticationFilter 的父类 AbstractAuthenticationProcessingFilter 中,这个类我们经常会见到,因为很多时候当我们想要在 Spring Security 自定义一个登录验证码或者将登录参数改为 JSON 的时候,我们都需自定义过滤器继承自 AbstractAuthenticationProcessingFilter ,毫无疑问,UsernamePasswordAuthenticationFilter#attemptAuthentication 方法就是在 AbstractAuthenticationProcessingFilter 类的 doFilter 方法中被触发的:

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
      throws IOException, ServletException {
   if (!requiresAuthentication(request, response)) {
      chain.doFilter(request, response);
      return;
   }
   try {
      // 返回认证信息
      Authentication authenticationResult = attemptAuthentication(request, response);
      if (authenticationResult == null) {
         // return immediately as subclass has indicated that it hasn't completed
         return;
      }
      // 实际调用org.springframework.security.web.authentication.session.NullAuthenticatedSessionStrategy#onAuthentication
      this.sessionStrategy.onAuthentication(authenticationResult, request, response);
      // Authentication success
      if (this.continueChainBeforeSuccessfulAuthentication) {
         chain.doFilter(request, response);
      }
      // 登录成功调用的方法
      successfulAuthentication(request, response, chain, authenticationResult);
   }
   catch (InternalAuthenticationServiceException failed) {
      this.logger.error("An internal error occurred while trying to authenticate the user.", failed);
      unsuccessfulAuthentication(request, response, failed);
   }
   catch (AuthenticationException ex) {
      // Authentication failed
      unsuccessfulAuthentication(request, response, ex);
   }
}

从上面的代码中,我们可以看到,当 attemptAuthentication 方法被调用时,实际上就是触发了 UsernamePasswordAuthenticationFilter#attemptAuthentication 方法,当登录抛出异常的时候,unsuccessfulAuthentication 方法会被调用,而当登录成功的时候,successfulAuthentication 方法则会被调用,那我们就来看一看 successfulAuthentication 方法:

protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
      Authentication authResult) throws IOException, ServletException {
   // 保存用户信息
   SecurityContextHolder.getContext().setAuthentication(authResult);
   if (this.logger.isDebugEnabled()) {
      this.logger.debug(LogMessage.format("Set SecurityContextHolder to %s", authResult));
   }
   this.rememberMeServices.loginSuccess(request, response, authResult);
   if (this.eventPublisher != null) {
      this.eventPublisher.publishEvent(new InteractiveAuthenticationSuccessEvent(authResult, this.getClass()));
   }
   // 登录成功的回调
   this.successHandler.onAuthenticationSuccess(request, response, authResult);
}

在这里有一段很重要的代码,就是 SecurityContextHolder.getContext().setAuthentication(authResult); ,登录成功的用户信息被保存在这里,也就是说,在任何地方,如果我们想获取用户登录信息,都可以从 SecurityContextHolder.getContext() 中获取到,想修改,也可以在这里修改。

最后大家还看到有一个 successHandler.onAuthenticationSuccess,这就是我们在 SecurityConfig 中配置登录成功回调方法,就是在这里被触发的,

SpringSecurity 控制同一账号,一人登录源码分析

前置操作:配置

	// 配置 同一账号只允许一人登录,A先登录,B后登录,A下线通知        
	http.sessionManagement(sessionManagement -> sessionManagement.maximumSessions(1)
        .expiredSessionStrategy(customSessionInformationExpiredStrategy())
        // A先登录,B后登录,B 登录抛出异常               
		//.maxSessionsPreventsLogin(true) 
    );
	// 可以看一下sessionManagement方法源码有提示
    @Bean
    public HttpSessionEventPublisher httpSessionEventPublisher() {
        return new HttpSessionEventPublisher();
    }

	@Bean
    public CustomSessionInformationExpiredStrategy customSessionInformationExpiredStrategy(){
        return new CustomSessionInformationExpiredStrategy(customAuthenticationFailureHandler());
    }
@Slf4j
@RequiredArgsConstructor
public class CustomSessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {

    public final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

    @Override
    public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
        UserDetails userDetails = (UserDetails) event.getSessionInformation().getPrincipal();
        // 响应消息
        ResponseUtil.out(event.getResponse(), R.error(HttpStatus.HTTP_CONFLICT, String.format("[%s]账号已在异地登录,您已被迫下线", userDetails.getUsername())));
    }
}
public class CustomSecurityWebApplicationInitializer extends AbstractSecurityWebApplicationInitializer {
    @Override
    protected boolean enableHttpSessionEventPublisher() {
        // org.springframework.security.config.annotation.web.builders.HttpSecurity#sessionManagement(org.springframework.security.config.Customizer<org.springframework.security.config.annotation.web.configurers.SessionManagementConfigurer<org.springframework.security.config.annotation.web.builders.HttpSecurity>>)
        // 为什么这样子写,可以看一下上面这个,源码有解释
        return true;
    }
}

源码分析

场景提示:当发生了同个账号多处登录的时候,此处为了方便简称A与B,A先登录,B后登陆

当B登录成功之后,请求过来之后(不是登录请求),进去这个过滤器

org.springframework.security.web.session.ConcurrentSessionFilter#doFilter(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, javax.servlet.FilterChain)

	private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
			throws IOException, ServletException {
        // 是否是第一次请求进来,第一次为空,也就是登录
		HttpSession session = request.getSession(false);
		if (session != null) { 
            // 通过当前请求的 sessionId 读取会话信息 
            // org.springframework.security.core.session.SessionRegistryImpl#getSessionInformation
			SessionInformation info = this.sessionRegistry.getSessionInformation(session.getId());
			if (info != null) {
                // 当前会话已过期
				if (info.isExpired()) {
					// Expired - abort processing
					this.logger.debug(LogMessage
							.of(() -> "Requested session ID " + request.getRequestedSessionId() + " has expired."));
                    // 此方法是登出方法 org.springframework.security.web.authentication.logout.LogoutHandler#logout
					doLogout(request, response);
                    // 这个就是我们前面配置的会话过期策略处理
					this.sessionInformationExpiredStrategy
							.onExpiredSessionDetected(new SessionInformationExpiredEvent(info, request, response));
					return;
				}
				// Non-expired - update last request date/time
				this.sessionRegistry.refreshLastRequest(info.getSessionId());
			}
		}
        // 因为B是后登陆,没有过期,就走下一个过滤器
		chain.doFilter(request, response);
	}

org.springframework.security.web.session.SessionManagementFilter#doFilter(javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, javax.servlet.FilterChain)

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)) {
         // The user has been authenticated during the current request, so call the
         // session strategy
         try {
            // 对当前请求的过滤器进行认证 org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy#onAuthentication
            this.sessionAuthenticationStrategy.onAuthentication(authentication, request, response);
         }
         catch (SessionAuthenticationException ex) {
            // The session strategy can reject the authentication
            this.logger.debug("SessionAuthenticationStrategy rejected the authentication object", ex);
            SecurityContextHolder.clearContext();
            this.failureHandler.onAuthenticationFailure(request, response, ex);
            return;
         }
         // Eagerly save the security context to make it available for any possible
         // re-entrant requests which may occur before the current request
         // completes. SEC-1396.
         this.securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response);
      }
      else {
         // No security context or authentication present. Check for a session
         // timeout
         if (request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid()) {
            if (this.logger.isDebugEnabled()) {
               this.logger.debug(LogMessage.format("Request requested invalid session id %s",
                     request.getRequestedSessionId()));
            }
            if (this.invalidSessionStrategy != null) {
               this.invalidSessionStrategy.onInvalidSessionDetected(request, response);
               return;
            }
         }
      }
   }
   chain.doFilter(request, response);
}
public void onAuthentication(Authentication authentication, HttpServletRequest request,
      HttpServletResponse response) throws SessionAuthenticationException {
   int currentPosition = 0;
   int size = this.delegateStrategies.size();
   for (SessionAuthenticationStrategy delegate : this.delegateStrategies) {
      if (this.logger.isTraceEnabled()) {
         this.logger.trace(LogMessage.format("Preparing session with %s (%d/%d)",
               delegate.getClass().getSimpleName(), ++currentPosition, size));
      }
      // 调用过滤器开始认证
      // ConcurrentSessionControlAuthenticationStrategy
      // AbstractSessionFixationProtectionStrategy
      // RegisterSessionAuthenticationStrategy
      delegate.onAuthentication(authentication, request, response);
   }
}

ConcurrentSessionControlAuthenticationStrategy 策略

//org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy#onAuthentication
public void onAuthentication(Authentication authentication, HttpServletRequest request,
			HttpServletResponse response) {
    	// 查询当前用户未过期的会话信息
		List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(authentication.getPrincipal(), false);
		int sessionCount = sessions.size();
    	// 这个就是我们前面设置的 sessionManagement.maximumSessions(1)
		int allowedSessions = getMaximumSessionsForThisUser(authentication);
		if (sessionCount < allowedSessions) {
			// They haven't got too many login sessions running at present
			return;
		}
    	// 没有限制
		if (allowedSessions == -1) {
			// We permit unlimited logins
			return;
		}
		if (sessionCount == allowedSessions) {
			HttpSession session = request.getSession(false);
			if (session != null) {
				// Only permit it though if this request is associated with one of the
				// already registered sessions
				for (SessionInformation si : sessions) {
					if (si.getSessionId().equals(session.getId())) {
						return;
					}
				}
			}
			// If the session is null, a new one will be created by the parent class,
			// exceeding the allowed number
		}
    	// 开始处理过期的会话消息
		allowableSessionsExceeded(sessions, allowedSessions, this.sessionRegistry);
	}
	protected void allowableSessionsExceeded(List<SessionInformation> sessions, int allowableSessions,
			SessionRegistry registry) throws SessionAuthenticationException {
        // exceptionIfMaximumExceeded 这个就是我们前面设置的 sessionManagement.maxSessionsPreventsLogin(true) 
        // 设置了这个为true B请求的时候就会直接报错
		if (this.exceptionIfMaximumExceeded || (sessions == null)) {
			throw new SessionAuthenticationException(
					this.messages.getMessage("ConcurrentSessionControlAuthenticationStrategy.exceededAllowed",
							new Object[] { allowableSessions }, "Maximum sessions of {0} for this principal exceeded"));
		}
		// Determine least recently used sessions, and mark them for invalidation
        // 对当前的会话进行一个降序排序
		sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
		int maximumSessionsExceededBy = sessions.size() - allowableSessions + 1;
		List<SessionInformation> sessionsToBeExpired = sessions.subList(0, maximumSessionsExceededBy);
		for (SessionInformation session : sessionsToBeExpired) {
            // 通过sessionId将会话标记为过期
            // 也就解释了下次A 请求的时候在ConcurrentSessionFilter为什么登陆不了的原因
			session.expireNow();
		}
	}

AbstractSessionFixationProtectionStrategy 策略

	public void onAuthentication(Authentication authentication, HttpServletRequest request,
			HttpServletResponse response) {
        // 当前请求没有会话
		boolean hadSessionAlready = request.getSession(false) != null;
        // alwaysCreateSession 如果设置为 true,将始终创建会话,即使在请求开始时不存在会话。默认为  false。
		if (!hadSessionAlready && !this.alwaysCreateSession) {
			// Session fixation isn't a problem if there's no session
			return;
		}
		// 创建一个新的会话
		HttpSession session = request.getSession();
		if (hadSessionAlready && request.isRequestedSessionIdValid()) {
			String originalSessionId;
			String newSessionId;
			Object mutex = WebUtils.getSessionMutex(session);
			synchronized (mutex) {
				// We need to migrate to a new session
				originalSessionId = session.getId();
				session = applySessionFixation(request);
				newSessionId = session.getId();
			}
			if (originalSessionId.equals(newSessionId)) {
				this.logger.warn("Your servlet container did not change the session ID when a new session "
						+ "was created. You will not be adequately protected against session-fixation attacks");
			}
			else {
				if (this.logger.isDebugEnabled()) {
					this.logger.debug(LogMessage.format("Changed session id from %s", originalSessionId));
				}
			}
			onSessionChange(originalSessionId, session, authentication);
		}
	

RegisterSessionAuthenticationStrategy 策略

	// 添加一个新的会话信息
	public void registerNewSession(String sessionId, Object principal) {
		Assert.hasText(sessionId, "SessionId required as per interface contract");
		Assert.notNull(principal, "Principal required as per interface contract");
		if (getSessionInformation(sessionId) != null) {
			removeSessionInformation(sessionId);
		}
		if (this.logger.isDebugEnabled()) {
			this.logger.debug(LogMessage.format("Registering session %s, for principal %s", sessionId, principal));
		}
        // 添加一个新的会话信息到org.springframework.security.core.session.SessionRegistryImpl#sessionIds
		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);
			this.logger.trace(LogMessage.format("Sessions used by '%s' : %s", principal, sessionsUsedByPrincipal));
			return sessionsUsedByPrincipal;
		});
	}

总结:

在SpringSecurity中设置同一个账号只允许一人登录需要对接方传递sessionId来服务器,否则这个判断还是会是失效的,对于同一账号一人登录是依赖于sessionId的,sessionId存储至org.springframework.security.core.session.SessionRegistryImpl#sessionIds

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值