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 本身是一个接口,它有很多实现类:
在这众多的实现类中,我们最常用的就是 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));
}
}
根据这段源码我们可以看出:
- 21行,判断当前请求是否是POST方式提交,前提是 “postOnly” 为 true
- 24行与27行 首先通过 obtainUsername 和 obtainPassword 方法提取出请求里边的用户名/密码出来,提取方式就是 request.getParameter ,这也是为什么 Spring Security 中默认的表单登录要通过 key/value 的形式传递参数,而不能传递 JSON 参数,如果像传递 JSON 参数,修改这里的逻辑即可。
- 获取到请求里传递来的用户名/密码之后,接下来就构造一个 UsernamePasswordAuthenticationToken 对象,传入 username 和 password,username 对应了 UsernamePasswordAuthenticationToken 中的 principal 属性,而 password 则对应了它的 credentials 属性。
- 接下来 setDetails 方法给 details 属性赋值,UsernamePasswordAuthenticationToken 本身是没有 details 属性的,这个属性在它的父类 AbstractAuthenticationToken 中。details 是一个对象,这个对象里边放的是 WebAuthenticationDetails 实例,该实例主要描述了两个信息,请求的 remoteAddress 以及请求的 sessionId
- 最后一步,就是调用 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;
}
这个方法就比较魔幻了,因为几乎关于认证的重要逻辑都将在这里完成:
- 首先获取 authentication 的 Class,判断当前 provider 是否支持该 authentication。
- 如果支持,则调用 provider 的 authenticate 方法开始做校验,校验完成后,会返回一个新的 Authentication。一会来和大家捋这个方法的具体逻辑。
- 这里的 provider 可能有多个,如果 provider 的 authenticate 方法没能正常返回一个 Authentication,则调用 provider 的 parent 的 authenticate 方法继续校验。
- copyDetails 方法则用来把旧的 Token 的 details 属性拷贝到新的 Token 中来。
- 接下来会调用 eraseCredentials 方法擦除凭证信息,也就是你的密码,这个擦除方法比较简单,就是将 Token 中的 credentials 属性置空。
- 最后通过 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);
}
这里的逻辑就比较简单了:
- 首先从 Authentication 提取出登录用户名。
- 然后通过拿着 username 去调用 retrieveUser 方法去获取当前用户对象,这一步会调用我们自己在登录时候的写的 loadUserByUsername 方法,所以这里返回的 user 其实就是你的登录对象
- 接下来调用 preAuthenticationChecks.check 方法去检验 user 中的各个账户状态属性是否正常,例如账户是否被禁用、账户是否被锁定、账户是否过期等等。
- additionalAuthenticationChecks 方法则是做密码比对的,好多小伙伴好奇 Spring Security 的密码加密之后,是如何进行比较的,看这里就懂了,因为比较的逻辑很简单,我这里就不贴代码出来了。
- 最后在 postAuthenticationChecks.check 方法中检查密码是否过期。
- 接下来有一个 forcePrincipalAsString 属性,这个是是否强制将 Authentication 中的 principal 属性设置为字符串,这个属性我们一开始在 UsernamePasswordAuthenticationFilter 类中其实就是设置为字符串的(即 username),但是默认情况下,当用户登录成功之后, 这个属性的值就变成当前用户这个对象了。之所以会这样,就是因为 forcePrincipalAsString 默认为 false,不过这块其实不用改,就用 false,这样在后期获取当前用户信息的时候反而方便很多。
- 最后,通过 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