refactor(security): 修复会话并发限制不生效问题

This commit is contained in:
vertoryao 2025-06-03 00:24:05 +08:00
parent f0066d4c64
commit d1834b404b
6 changed files with 81 additions and 83 deletions

View File

@ -8,12 +8,14 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException; import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.csrf.MissingCsrfTokenException; import org.springframework.security.web.csrf.MissingCsrfTokenException;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Map;
/** /**
* @author harry_yao * @author harry_yao
@ -27,7 +29,7 @@ public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override @Override
public void handle(HttpServletRequest request, HttpServletResponse response, public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException ex) throws IOException, ServletException { AccessDeniedException ex) throws IOException, ServletException {
ex.printStackTrace(); // ex.printStackTrace();
response.setContentType("application/json;charset=utf-8"); response.setContentType("application/json;charset=utf-8");
ExceptionResult result; ExceptionResult result;
if (ex instanceof MissingCsrfTokenException) { if (ex instanceof MissingCsrfTokenException) {
@ -36,6 +38,11 @@ public class CustomAccessDeniedHandler implements AccessDeniedHandler {
response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setStatus(HttpStatus.UNAUTHORIZED.value());
result = new ExceptionResult("凭证已过期,请重新登录", HttpStatus.UNAUTHORIZED.value(), result = new ExceptionResult("凭证已过期,请重新登录", HttpStatus.UNAUTHORIZED.value(),
LocalDateTime.now()); LocalDateTime.now());
} else if (ex instanceof AuthorizationDeniedException) {
// 403
response.setStatus(HttpStatus.FORBIDDEN.value());
result = new ExceptionResult("当前账号已在其他设备登录,请先退出再尝试登录", HttpStatus.FORBIDDEN.value(),
LocalDateTime.now());
} else { } else {
// 403 // 403
response.setStatus(HttpStatus.FORBIDDEN.value()); response.setStatus(HttpStatus.FORBIDDEN.value());

View File

@ -24,7 +24,7 @@ public class CustomSessionInformationExpiredStrategy implements SessionInformati
response.setContentType("application/json;charset=utf-8"); response.setContentType("application/json;charset=utf-8");
ObjectMapper objectMapper = new ObjectMapper(); ObjectMapper objectMapper = new ObjectMapper();
response.getWriter().print(objectMapper.writeValueAsString(Map.of( response.getWriter().print(objectMapper.writeValueAsString(Map.of(
"msg", "会话已过期(有可能是您同时登录了太多的太多的客户端)", "msg", "会话已过期(有可能是您同时登录了太多的客户端)",
"code", HttpStatus.UNAUTHORIZED.value(), "code", HttpStatus.UNAUTHORIZED.value(),
"timestamp", LocalDateTime.now() "timestamp", LocalDateTime.now()
))); )));

View File

@ -1,30 +0,0 @@
package com.zsc.edu.dify.framework.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.session.HttpSessionEventPublisher;
/**
* @author harry_yao
*/
@Configuration
public class SecurityBeanConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
}

View File

@ -12,11 +12,15 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.session.HttpSessionEventPublisher; import org.springframework.security.web.session.HttpSessionEventPublisher;
@ -35,31 +39,51 @@ public class SpringSecurityConfig {
private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler; private final CustomAuthenticationSuccessHandler customAuthenticationSuccessHandler;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomAccessDeniedHandler customAccessDeniedHandler; private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final SessionRegistry sessionRegistry; // private final SessionRegistry sessionRegistry;
private final SecurityBeanConfig securityBeanConfig;
private final CustomSessionInformationExpiredStrategy customSessionInformationExpiredStrategy; private final CustomSessionInformationExpiredStrategy customSessionInformationExpiredStrategy;
@Resource @Resource
private final DataSource dataSource; private final DataSource dataSource;
// @Bean @Bean
// public BCryptPasswordEncoder bCryptPasswordEncoder() { public HttpSessionSecurityContextRepository httpSessionSecurityContextRepository() {
// return new BCryptPasswordEncoder(); return new HttpSessionSecurityContextRepository();
// }; }
@Bean
public ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy() {
ConcurrentSessionControlAuthenticationStrategy concurrentSessionControlAuthenticationStrategy = new ConcurrentSessionControlAuthenticationStrategy(sessionRegistry());
concurrentSessionControlAuthenticationStrategy.setMaximumSessions(1);
return concurrentSessionControlAuthenticationStrategy;
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public SessionRegistry sessionRegistry() {
return new SessionRegistryImpl();
}
@Bean
public HttpSessionEventPublisher httpSessionEventPublisher() {
return new HttpSessionEventPublisher();
}
@Bean @Bean
public PersistentTokenRepository persistentTokenRepository() { public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl();
tokenRepository.setDataSource(dataSource); tokenRepository.setDataSource(dataSource);
return tokenRepository; return tokenRepository;
} }
@Bean @Bean
AuthenticationManager authenticationManager() { AuthenticationManager authenticationManager() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider(); DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setUserDetailsService(userDetailsService); daoAuthenticationProvider.setUserDetailsService(userDetailsService);
daoAuthenticationProvider.setPasswordEncoder(securityBeanConfig.passwordEncoder()); daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
return new ProviderManager(daoAuthenticationProvider); return new ProviderManager(daoAuthenticationProvider);
} }
@ -71,7 +95,8 @@ public class SpringSecurityConfig {
filter.setFilterProcessesUrl("/api/rest/user/login"); filter.setFilterProcessesUrl("/api/rest/user/login");
filter.setAuthenticationManager(authenticationManager()); filter.setAuthenticationManager(authenticationManager());
// 将登录后的请求信息保存到Session中不然会报null // 将登录后的请求信息保存到Session中不然会报null
filter.setSecurityContextRepository(new HttpSessionSecurityContextRepository()); // filter.setSessionAuthenticationStrategy(concurrentSessionControlAuthenticationStrategy());
// filter.setSecurityContextRepository(httpSessionSecurityContextRepository());
return filter; return filter;
} }
@ -86,43 +111,6 @@ public class SpringSecurityConfig {
.requestMatchers("/v1/**").authenticated() .requestMatchers("/v1/**").authenticated()
.requestMatchers("/api/**").authenticated() .requestMatchers("/api/**").authenticated()
) )
// 不用注解直接通过判断路径实现动态访问权限
// .requestMatchers("/api/**").access((authentication, object) -> {
// //表示请求的 URL 地址和数据库的地址是否匹配上了
// boolean isMatch = false;
// //获取当前请求的 URL 地址
// String requestURI = object.getRequest().getRequestURI();
// List<MenuWithRoleVO> menuWithRole = menuService.getMenuWithRole();
// for (MenuWithRoleVO m : menuWithRole) {
// AntPathMatcher antPathMatcher = new AntPathMatcher();
// if (antPathMatcher.match(m.getUrl(), requestURI)) {
// isMatch = true;
// //说明找到了请求的地址了
// //这就是当前请求需要的角色
// List<Role> roles = m.getRoles();
// //获取当前登录用户的角色
// Collection<? extends GrantedAuthority> authorities = authentication.get().getAuthorities();
// for (GrantedAuthority authority : authorities) {
// for (Role role : roles) {
// if (authority.getAuthority().equals(role.getName())) {
// //说明当前登录用户具备当前请求所需要的角色
// return new AuthorizationDecision(true);
// }
// }
// }
// }
// }
// if (!isMatch) {
// //说明请求的 URL 地址和数据库的地址没有匹配上对于这种请求统一只要登录就能访问
// if (authentication.get() instanceof AnonymousAuthenticationToken) {
// return new AuthorizationDecision(false);
// } else {
// //说明用户已经认证了
// return new AuthorizationDecision(true);
// }
// }
// return new AuthorizationDecision(false);
// }))
.addFilterAt(jsonAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .addFilterAt(jsonAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class)
.formLogin(form -> form .formLogin(form -> form
.loginPage("/user/login") .loginPage("/user/login")
@ -140,11 +128,13 @@ public class SpringSecurityConfig {
.rememberMe(rememberMe -> rememberMe .rememberMe(rememberMe -> rememberMe
.userDetailsService(userDetailsService) .userDetailsService(userDetailsService)
.tokenRepository(persistentTokenRepository())) .tokenRepository(persistentTokenRepository()))
.csrf(csrf -> csrf.ignoringRequestMatchers("v1/**","/api/internal/**", "/api/rest/user/logout","/api/rest/user/register")) .csrf(csrf ->
csrf.ignoringRequestMatchers("v1/**","/api/internal/**", "/api/rest/user/logout","/api/rest/user/register"))
.sessionManagement(session -> session .sessionManagement(session -> session
.maximumSessions(1) .maximumSessions(1)
.sessionRegistry(sessionRegistry) .maxSessionsPreventsLogin(true)
.expiredSessionStrategy(customSessionInformationExpiredStrategy)) .sessionRegistry(sessionRegistry())
.build(); // .expiredSessionStrategy(customSessionInformationExpiredStrategy)
).build();
} }
} }

View File

@ -12,6 +12,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection; import java.util.Collection;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -98,4 +99,34 @@ public class UserDetailsImpl implements UserDetails {
return enableState; return enableState;
} }
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
UserDetailsImpl that = (UserDetailsImpl) o;
return Objects.equals(id, that.id);
}
@Override
public int hashCode() {
return Objects.hash(id, username, password, enableState, name, dept, role, roles, authorities, permissions, dataScopeDeptIds, deptId, createId);
}
@Override
public String toString() {
return "UserDetailsImpl{" +
"id=" + id +
", username='" + username + '\'' +
", password='" + password + '\'' +
", enableState=" + enableState +
", name='" + name + '\'' +
", dept=" + dept +
", role=" + role +
", roles=" + roles +
", authorities=" + authorities +
", permissions=" + permissions +
", dataScopeDeptIds=" + dataScopeDeptIds +
", deptId=" + deptId +
", createId=" + createId +
'}';
}
} }