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

Spring Boot OAuth2 BFF+React认证流程实现问题求助

Spring Boot OAuth2 BFF对接React前端的认证故障排查与解决方案

当前实现配置

后端Spring Boot配置

Security配置类

@Configuration
public class Security {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return   http.cors(cors -> cors.configurationSource(request -> {
                    var corsConfiguration = new CorsConfiguration();
                    corsConfiguration.addAllowedOrigin("http://127.0.0.1:3000");
                    corsConfiguration.setAllowCredentials(true);
                    corsConfiguration.addAllowedMethod("*");
                    corsConfiguration.addAllowedHeader("*");
                    return corsConfiguration;
                }))
                .csrf()
                .disable()
                .authorizeHttpRequests()
                .anyRequest().authenticated()
                .and()
                .oauth2Login( oauth2Login -> oauth2Login.loginPage("/oauth2/authorization/securio"))
                .oauth2Client(Customizer.withDefaults())
                .build();

    }

}

认证控制器

@Controller
@RequiredArgsConstructor
@RequestMapping("/auth")
public class AuthenticationController {

    private final RestTemplate restTemplate;
    private final OAuth2AuthorizedClientService authorizedClientService;


     @GetMapping("/userinfo")
public ResponseEntity<UserInfo> getUserInfo() throws ParseException {

    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

    var client = authorizedClientService.loadAuthorizedClient(
            ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId(),
            authentication.getName());
    var accessToken = client.getAccessToken().getTokenValue();

    JWT jwt = JWTParser.parse(accessToken);

    List<String> authorities = jwt.getJWTClaimsSet().getStringListClaim("authorities");
    String userRole = null;
    for (String authority : authorities) {
        if (authority.startsWith("ROLE_")) {
            userRole = authority;
            break;
        }
    }
    if (userRole == null) {
        return new ResponseEntity<>(HttpStatus.UNAUTHORIZED);
    }

    String username = jwt.getJWTClaimsSet().getSubject();
    

    return new ResponseEntity<>(UserInfo.builder()
            .username(username)
            .role(userRole)
            .build(), HttpStatus.OK);

}

    @PostMapping("/logout")
    @ResponseStatus(HttpStatus.OK)
    public void logout(HttpServletRequest request, HttpServletResponse response) {

        HttpSession session = request.getSession(false);
        if (session != null) {

            ResponseEntity<Void> responseEntity = restTemplate.exchange(
                    "http://127.0.0.1:8082/auth/logout", HttpMethod.POST, null, Void.class);
            if (responseEntity.getStatusCode() != HttpStatus.NO_CONTENT) {
                throw new RuntimeException("Logout failed");
            }

            session.invalidate();

            Cookie cookie = new Cookie("JSESSIONID", "");
            cookie.setMaxAge(0);
            cookie.setPath("/");
            response.addCookie(cookie);
        } else {
            throw new RuntimeException("User already logged out");
        }

    }

}

配置文件application.yml

server:
  port: 8081

logging:
  level:
    org.springframework:
      security: trace

spring:
  security:
    oauth2:
      client:
        registration:
          securio:
            client-id: securio
            client-secret: securio-secret
            authorization-grant-type: authorization_code
            redirect-uri: http://127.0.0.1:8081/login/oauth2/code/securio
            scope: openid
            provider: securio
        provider:
          securio:
            issuer-uri: http://localhost:8082

前端React请求逻辑

useEffect(() => {
    axios
      .get('http://127.0.0.1:8081/auth/userinfo', {
      })
      .then((response) => {
        switch (response.data.role) {
          case 'ROLE_STANDARD_USER':
            setRole('ROLE_STANDARD_USER');
            setMenuItems(standardMenuItems);
            break;
          case 'ROLE_ADMIN':
            setRole('ROLE_ADMIN');
            setMenuItems(adminMenuItems);
            break;
          default:
            setRole(null);
            setMenuItems([]);
            break;
        }
      })
      .catch((error) => {
        console.log(error); // handle error
      });

预期认证流程

  1. 用户通过前端请求BFF的/auth/userinfo接口;
  2. 若未认证,BFF将前端重定向至授权服务器的/authorize端点;
  3. 用户输入凭证后,授权服务器携带授权码重定向回BFF;
  4. BFF获取Access Token、Refresh Token等并与用户会话存储;
  5. 向前端返回用户信息。

核心问题

  1. CORS配置问题:前端、BFF、授权服务器三个服务交互时,BFF重定向前端到授权服务器的请求中,浏览器发送的Origin为null(隐私设置限制),导致授权服务器的CORS验证失败,即使已配置允许相关源。
  2. 前端重定向处理问题:即使授权服务器允许所有源(*),axios请求收到重定向响应后,无法自动跳转到授权服务器的登录页面,仅返回HTML表单内容,用户无法完成凭证输入。

解决方案

1. 调整前端认证触发逻辑,避免用AJAX请求触发重定向

浏览器的XMLHttpRequest/fetch请求会遵循同源策略,且不会自动处理3xx重定向到跨域地址。正确的做法是让浏览器直接发起跳转,而非通过AJAX:

useEffect(() => {
  fetch('http://127.0.0.1:8081/auth/userinfo', {
    credentials: 'include' // 携带会话Cookie
  })
  .then(res => {
    if (res.status === 401 || res.status === 302) {
      // 未认证,直接跳转到BFF的OAuth2授权入口
      window.location.href = 'http://127.0.0.1:8081/oauth2/authorization/securio';
      return;
    }
    return res.json();
  })
  .then(userInfo => {
    // 处理用户角色和菜单
    switch (userInfo.role) {
      case 'ROLE_STANDARD_USER':
        setRole('ROLE_STANDARD_USER');
        setMenuItems(standardMenuItems);
        break;
      case 'ROLE_ADMIN':
        setRole('ROLE_ADMIN');
        setMenuItems(adminMenuItems);
        break;
      default:
        setRole(null);
        setMenuItems([]);
        break;
    }
  })
  .catch(err => {
    // 网络错误或未认证,直接跳转授权页面
    window.location.href = 'http://127.0.0.1:8081/oauth2/authorization/securio';
  });
}, []);

2. 优化BFF的CORS和会话配置

  • 重构CORS配置为独立Bean,提升可维护性:
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(Arrays.asList("http://127.0.0.1:3000"));
    configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    configuration.setAllowedHeaders(Arrays.asList("*"));
    configuration.setAllowCredentials(true);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}

// 更新SecurityFilterChain配置
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf().disable()
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .oauth2Login(oauth2 -> oauth2
                    .loginPage("/oauth2/authorization/securio")
                    .defaultSuccessUrl("http://127.0.0.1:3000", true)) // 认证成功后重定向回前端
            .oauth2Client(Customizer.withDefaults())
            .build();
}
  • 添加defaultSuccessUrl,让BFF在OAuth2认证成功后直接跳回前端页面,前端再重新请求/auth/userinfo获取用户信息。

3. 调整授权服务器的CORS策略

授权服务器的登录页面属于浏览器直接访问的资源,不需要配置CORS。CORS仅针对跨域AJAX请求,浏览器直接跳转的请求不受CORS限制。因此可以移除授权服务器上针对/authorize或登录页面的CORS配置,避免因Origin为null导致的验证失败。

4. 优化BFF的用户信息接口

确保Spring Security的过滤器优先处理认证逻辑,未认证请求会被自动重定向到授权页面,无需在/auth/userinfo中手动处理未认证场景。

调整后的完整流程

  1. 前端页面加载,直接请求BFF的/auth/userinfo(携带凭证);
  2. 若未认证,BFF返回302重定向到授权服务器的/authorize端点;
  3. 浏览器自动跳转到授权服务器登录页面,用户输入凭证;
  4. 授权服务器携带授权码重定向回BFF的/login/oauth2/code/securio;
  5. BFF完成授权码交换,获取Token并创建用户会话;
  6. BFF重定向回前端页面;
  7. 前端页面重新加载,再次请求/auth/userinfo,BFF返回用户角色信息;
  8. 前端根据角色渲染对应菜单。

内容的提问来源于stack exchange,提问作者loredon

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.27 22:32:55