Vaadin+Spring Security:Save按钮点击后SecurityContext丢失用户问题排查
问题
已实现用户登录功能,用户数据取自MySQL数据库,登录后进入/grid页面。点击该页面的Save按钮尝试保存数据至MySQL时出现异常:按钮触发的方法中登录用户变为anonymous,Spring Security的SecurityContext丢失用户信息,但VaadinSession仍能识别登录用户。
控制台日志:
User successful logged in: fish1 Logged user: fish1 Username while button is pressing in /grid: fish1 User in SecurityContext in class EnterProductService: anonymousUser User in VaadinSession in class EnterProductService: fish1
相关代码
EnterProductService类
@Service public class EnterProductService { @Autowired private RestTemplate restTemplate; public String urlForMapping = null; public boolean enteredProductError = false; public boolean reqOpenFoodFacts = false; public boolean reqFoodDataCentral = false; public String reqBarcode = null; public MealType reqMealType = null; public double reqGrams = 0; private final Executor executor; @Autowired public EnterProductService(@Qualifier("taskExecutor") Executor executor) { this.executor = new DelegatingSecurityContextExecutor(executor); } @PreAuthorize("hasRole('USER')") @Transactional public void onSaveButtonClicked(String searchProductField, String gramAmount, MealType mealType) { SecurityContext securityContext = SecurityContextHolder.getContext(); VaadinSession currentSession = VaadinSession.getCurrent(); UI currentUI = UI.getCurrent(); executor.execute(() -> { VaadinSession.setCurrent(currentSession); try { currentSession.lock(); currentUI.access(() -> { SecurityContextHolder.setContext(securityContext); System.out.println("User in SecurityContext in class EnterProductService: " + SecurityContextHolder.getContext().getAuthentication().getName()); System.out.println("User in VaadinSession in class EnterProductService: " + VaadinSession.getCurrent().getSession().getAttribute("username")); if (searchProductField != null && mealType != null && gramAmount != null) { if (isOnlyNumeric(searchProductField)) { reqBarcode = searchProductField; reqGrams = Double.parseDouble(gramAmount); reqMealType = mealType; urlForMapping = String.format("/openfoodfacts/%s/%s/%s", searchProductField, gramAmount, mealType.name()); reqOpenFoodFacts = true; reqFoodDataCentral = false; } else if (isOnlyLetters(searchProductField)) { reqBarcode = searchProductField; reqGrams = Double.parseDouble(gramAmount); reqMealType = mealType; urlForMapping = String.format("/fooddatacentral/%s/%s/%s", searchProductField, gramAmount, mealType.name()); reqFoodDataCentral = true; reqOpenFoodFacts = false; } else { enteredProductError = true; reqOpenFoodFacts = false; reqFoodDataCentral = false; } } }); } finally { currentSession.unlock(); } }); } public void sendRequestToApi() { String url = urlForMapping; String response = restTemplate.getForObject("http://localhost:8080/product" + url, String.class); } public boolean isOnlyNumeric (String string){ return string.matches("\\d+"); } public boolean isOnlyLetters (String string){ return string.matches("[a-zA-Z]+"); } }
按钮点击事件代码
saveButton.addClickListener(event -> { System.out.println("Username while button is pressing in /grid " + username); enterProductService.onSaveButtonClicked( searchProductField.getValue(), gramAmount.getValue(), comboBox.getValue()); enterProductService.sendRequestToApi(); });
SecurityConfig配置类
@Configuration @EnableWebSecurity public class SecurityConfig extends VaadinWebSecurity { private final UserService userService; @Autowired public SecurityConfig(@Lazy UserService userService) { this.userService = userService; } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/login", "/user", "/VAADIN/**", "/UIDL/**", "/HEARTBEAT/**", "/resources/**", "/websocket-client.js").permitAll() .requestMatchers("/grid").hasRole("USER") .anyRequest().permitAll() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/grid", true) .permitAll() ) .logout(logout -> logout // .logoutUrl("/logout") .logoutSuccessUrl("/login") .permitAll() ) .csrf(csrf -> csrf.disable()); return http.build(); } @Bean public AuthenticationManager authenticationManager(PasswordEncoder passwordEncoder) { DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); authenticationProvider.setUserDetailsService(userService); authenticationProvider.setPasswordEncoder(passwordEncoder); return new ProviderManager(authenticationProvider); } }
问题分析与解决
核心原因
SecurityContext丢失的原因:
Spring Security的SecurityContext默认绑定在当前请求线程,是线程局部变量。你虽然使用了DelegatingSecurityContextExecutor来传递上下文,但代码中嵌套了UI.access(),将逻辑切换到Vaadin的UI线程,而DelegatingSecurityContextExecutor只会把上下文传递到executor线程,不会自动同步到UI线程;同时手动捕获并设置SecurityContext的操作也干扰了DelegatingSecurityContextExecutor的自动传递逻辑。VaadinSession保留用户信息的原因:
VaadinSession是Vaadin自身的会话管理对象,存储在HttpSession中。你通过VaadinSession.setCurrent(currentSession)将当前会话手动绑定到了异步线程,因此可以从会话属性中读取到用户信息。
修复步骤
1. 正确使用DelegatingSecurityContextExecutor
移除手动捕获和设置SecurityContext的代码,让DelegatingSecurityContextExecutor自动完成上下文传递:
@PreAuthorize("hasRole('USER')") @Transactional public void onSaveButtonClicked(String searchProductField, String gramAmount, MealType mealType) { VaadinSession currentSession = VaadinSession.getCurrent(); UI currentUI = UI.getCurrent(); executor.execute(() -> { // 此处已由DelegatingSecurityContextExecutor自动传入SecurityContext System.out.println("User in SecurityContext in class EnterProductService: " + SecurityContextHolder.getContext().getAuthentication().getName()); System.out.println("User in VaadinSession in class EnterProductService: " + currentSession.getSession().getAttribute("username")); // 用局部变量存储业务结果,避免多线程竞争 boolean localError = false; String localUrl = null; boolean localOpenFoodFacts = false; boolean localFoodDataCentral = false; String localBarcode = null; MealType localMealType = null; double localGrams = 0; if (searchProductField != null && mealType != null && gramAmount != null) { if (isOnlyNumeric(searchProductField)) { localBarcode = searchProductField; localGrams = Double.parseDouble(gramAmount); localMealType = mealType; localUrl = String.format("/openfoodfacts/%s/%s/%s", searchProductField, gramAmount, mealType.name()); localOpenFoodFacts = true; localFoodDataCentral = false; } else if (isOnlyLetters(searchProductField)) { localBarcode = searchProductField; localGrams = Double.parseDouble(gramAmount); localMealType = mealType; localUrl = String.format("/fooddatacentral/%s/%s/%s", searchProductField, gramAmount, mealType.name()); localFoodDataCentral = true; localOpenFoodFacts = false; } else { localError = true; } } // 仅在需要更新UI或类状态时使用UI.access() currentUI.access(() -> { try { currentSession.lock(); this.enteredProductError = localError; this.urlForMapping = localUrl; this.reqOpenFoodFacts = localOpenFoodFacts; this.reqFoodDataCentral = localFoodDataCentral; this.reqBarcode = localBarcode; this.reqMealType = localMealType; this.reqGrams = localGrams; } finally { currentSession.unlock(); } }); // 在executor线程中调用API,确保SecurityContext生效 if (!localError) { sendRequestToApi(localUrl); } }); }
2. 修复sendRequestToApi的线程安全问题
将url作为参数传递,避免依赖类成员变量导致的多线程竞争:
public void sendRequestToApi(String url) { String response = restTemplate.getForObject("http://localhost:8080/product" + url, String.class); }
3. 优化SecurityConfig配置
继承VaadinWebSecurity后需调用父类的configure方法,启用Vaadin默认安全配置,并调整权限规则更严谨:
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { super.configure(http); // 启用Vaadin默认安全配置 http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/login", "/user").permitAll() .requestMatchers("/grid").hasRole("USER") .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/grid", true) .permitAll() ) .logout(logout -> logout .logoutSuccessUrl("/login") .permitAll() ); // VaadinWebSecurity会自动处理CSRF,无需手动禁用 return http.build(); }
4. 修复按钮点击事件的竞态问题
移除主线程中直接调用sendRequestToApi()的代码,确保仅在异步业务逻辑完成后再调用API:
saveButton.addClickListener(event -> { System.out.println("Username while button is pressing in /grid " + username); enterProductService.onSaveButtonClicked( searchProductField.getValue(), gramAmount.getValue(), comboBox.getValue()); // 移除此处的sendRequestToApi调用,改在异步逻辑中执行 });
内容的提问来源于stack exchange,提问作者SimiSimi

