You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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);
    }
}

问题分析与解决

核心原因

  1. SecurityContext丢失的原因:
    Spring Security的SecurityContext默认绑定在当前请求线程,是线程局部变量。你虽然使用了DelegatingSecurityContextExecutor来传递上下文,但代码中嵌套了UI.access(),将逻辑切换到Vaadin的UI线程,而DelegatingSecurityContextExecutor只会把上下文传递到executor线程,不会自动同步到UI线程;同时手动捕获并设置SecurityContext的操作也干扰了DelegatingSecurityContextExecutor的自动传递逻辑。

  2. 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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.17 03:04:51