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

Spring Boot集成Flutter登录时遇302重定向错误求助

Spring Boot与Flutter集成登录时302重定向错误排查与解决

问题背景

在Spring Boot与Flutter集成开发的登录流程中,触发302重定向错误,导致登录失败无法继续。

问题详情

  • 现象:登录请求返回302重定向,流程被阻断,无法完成认证
  • 预期:登录成功完成用户认证,返回用户信息并跳转至对应页面
  • 实际:未完成登录,收到302响应,流程终止

请求协助

需要解决302重定向错误的指导,包括Spring Boot/Flutter端的排查步骤、配置修改建议。


附相关代码

Spring Boot SecurityConfig

package com.example.//.config;

import com.example.//.Service.UserDetailService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;

import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final UserDetailService userDetailService;

    private final ObjectMapper objectMapper;


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors
                        .configurationSource(CorsConfig.corsConfigurationSource())
                )
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/user/create", "/user/login", "/user/mypage").permitAll()
                        .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                )
                .formLogin(form -> form
                        .loginPage("/user/login").permitAll()
                        .usernameParameter("nickname")
                        .passwordParameter("password")
                        .successHandler(authenticationSuccessHandler())
                )

                .logout(logout -> logout
                        .logoutUrl("/user/logout")
                        .invalidateHttpSession(true)
                );

        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(daoAuthenticationProvider);
    }

    @Bean
    public AuthenticationSuccessHandler authenticationSuccessHandler() {
        return (request, response, auth) -> response.sendRedirect("/friends/page");
    }
}

Spring Boot CorsConfig

package com.example.blogback.config;


import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class CorsConfig {

    public static CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

     
        ArrayList<String> allowedOriginPatterns = new ArrayList<>();
        allowedOriginPatterns.add("http://localhost:");
        allowedOriginPatterns.add("http://localhost:*");

        configuration.setAllowedOrigins(allowedOriginPatterns);

       
        ArrayList<String> allowedHttpMethods = new ArrayList<>();
        allowedHttpMethods.add("GET");
        allowedHttpMethods.add("POST");
        allowedHttpMethods.add("PUT");
        allowedHttpMethods.add("DELETE");
        allowedHttpMethods.add("OPTIONS");
        configuration.setAllowedMethods(allowedHttpMethods);

        configuration.setAllowedHeaders(Collections.singletonList("*"));
       configuration.setAllowedHeaders(List.of(HttpHeaders.AUTHORIZATION, HttpHeaders.CONTENT_TYPE));

 
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);

        return source;
    }
}

Flutter Login页面

import 'package:flutter/material.dart';
import 'package:flutter_application_blog/page/mainPage.dart';
import 'package:flutter_application_blog/page/mypage.dart';
import 'package:flutter_application_blog/page/create_account.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:dio/dio.dart';

class Login extends StatefulWidget {
  const Login({super.key});

  @override
  _LoginState createState() => _LoginState();
}

class _LoginState extends State<Login> {
  final TextEditingController _nicknameController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();
  final _formKey = GlobalKey<FormState>();

  static final storage = FlutterSecureStorage();
  dynamic userInfo;

  @override
  void initState() {
    super.initState();
    _checkLoginStatus();
  }

  Future<void> _checkLoginStatus() async {
    userInfo = await storage.read(key: 'login');
    if (userInfo != null) {
      Navigator.pushNamed(context, '/main');
    }
  }

  Future<bool> loginAction(String nickname, String password) async {
    try {
      var dio = Dio(BaseOptions(
        baseUrl: "http://localhost:8081",
        headers: {"Accept": "application/json"},
      ));

      var param = {'nickname': nickname, 'password': password};

      Response response = await dio.post('/user/login', data: param);

      if (response.statusCode == 200) {
        final userId = response.data['user_id'];
        await storage.write(key: 'login', value: jsonEncode(userId));
        print('Login successful!');
        return true;
      } else {
        print('Login failed: ${response.statusCode}');
        return false;
      }
    } catch (e) {
      print('Error occurred: $e');
      return false;
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: _buildBody(),
    );
  }

  Widget _buildBody() {
    return Container(
      color: const Color(0xffFEFCEB),
      child: SingleChildScrollView(
        child: Column(
          children: [
            Container(
              width: 150,
              height: 150,
              margin: const EdgeInsets.only(top: 45, bottom: 20),
              child: Image.asset("logo.png"),
            ),
            Container(
              width: 330,
              height: 250,
              decoration: BoxDecoration(
                borderRadius: BorderRadius.circular(10),
                color: Colors.white,
                border: Border.all(width: 0.5, color: const Color(0xff1A495D)),
              ),
              child: Form(
                key: _formKey,
                child: Column(
                  children: [
                    _buildTextField(
                      controller: _nicknameController,
                      label: 'Nickname',
                    ),
                    _buildTextField(
                      controller: _passwordController,
                      label: 'Password',
                      obscureText: true,
                    ),
                  ],
                ),
              ),
            ),
            const SizedBox(height: 20),
            _buildLoginButton(),
            const SizedBox(height: 10),
            _buildSignUpPrompt(),
          ],
        ),
      ),
    );
  }

  Widget _buildLoginButton() {
    return Container(
      width: 100,
      height: 50,
      margin: const EdgeInsets.all(20),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(40),
        color: const Color(0xffB5DDDB),
      ),
      child: Center(
        child: GestureDetector(
          onTap: () async {
            if (_formKey.currentState!.validate()) {
              bool success = await loginAction(
                  _nicknameController.text, _passwordController.text);
              if (success) {
                print('로그인 성공');
                Navigator.of(context).push(
                  MaterialPageRoute(
                      builder: (context) => MainPage()), // CreateAccount로 수정
                );
              } else {
                print('로그인 실패');
              }
            }
          },
          child: const Text(
            'Login',
            style: TextStyle(
              color: Colors.black,
              fontSize: 18,
              fontWeight: FontWeight.bold,
            ),
          ),
        ),
      ),
    );
  }

  Widget _buildSignUpPrompt() {
    return Column(
      children: [
        const Text(
          "계정이 없으신가요?",
          style: TextStyle(color: Colors.grey, fontSize: 15),
        ),
        TextButton(
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(
                  builder: (context) => create_account()), // CreateAccount로 수정
            );
          },
          child: const Text(
            "회원가입",
            style: TextStyle(
              decoration: TextDecoration.underline,
              color: Colors.black,
            ),
          ),
        ),
      ],
    );
  }

  Widget _buildTextField({
    required TextEditingController controller,
    required String label,
    bool obscureText = false,
  }) {
    return Padding(
      padding: const EdgeInsets.only(top: 20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            label,
            style: const TextStyle(fontSize: 22),
            textAlign: TextAlign.left,
          ),
          Container(
            width: 250,
            margin: const EdgeInsets.only(top: 10),
            decoration: BoxDecoration(
              border: Border.all(width: 2, color: const Color(0xffB5DDDD)),
              borderRadius: BorderRadius.circular(10),
            ),
            child: TextField(
              controller: controller,
              obscureText: obscureText,
              decoration: InputDecoration(
                border: InputBorder.none,
                hintText: " ",
                contentPadding: const EdgeInsets.only(left: 10, top: 12),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

问题根源分析

302错误的核心原因是Spring Security的formLogin组件与前后端分离场景不兼容:

  1. formLogin是为传统服务端渲染页面设计的,登录成功后默认触发页面重定向(你的代码中successHandler明确调用了sendRedirect("/friends/page"))
  2. Flutter作为前端应用,需要JSON格式的登录结果而非页面跳转,302响应会被Dio默认拦截,导致前端无法正确获取登录状态

Spring Boot端修改方案

1. 移除formLogin,改用自定义API认证

修改SecurityConfig,删除formLogin相关配置,避免重定向机制:

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final UserDetailService userDetailService;
    private final ObjectMapper objectMapper;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .cors(cors -> cors.configurationSource(CorsConfig.corsConfigurationSource()))
                .csrf(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/user/create", "/user/login", "/user/mypage").permitAll()
                        .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                        .anyRequest().authenticated()
                )
                .logout(logout -> logout
                        .logoutUrl("/user/logout")
                        .invalidateHttpSession(true)
                );

        return http.build();
    }

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(daoAuthenticationProvider);
    }
}

2. 编写自定义登录接口

创建UserController,实现登录逻辑并返回JSON结果:

@RestController
@RequestMapping("/user")
@RequiredArgsConstructor
public class UserController {
    private final AuthenticationManager authenticationManager;

    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody LoginRequest request) {
        try {
            Authentication authentication = authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(request.getNickname(), request.getPassword())
            );
            SecurityContextHolder.getContext().setAuthentication(authentication);
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            return ResponseEntity.ok(Map.of("user_id", userDetails.getUsername()));
        } catch (AuthenticationException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("用户名或密码错误");
        }
    }

    public static class LoginRequest {
        private String nickname;
        private String password;

        // Getters and Setters
        public String getNickname() { return nickname; }
        public void setNickname(String nickname) { this.nickname = nickname; }
        public String getPassword() { return password; }
        public void setPassword(String password) { this.password = password; }
    }
}

3. 修复CorsConfig的重复配置问题

移除重复的allowedHeaders设置,确保CORS规则正确:

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class CorsConfig {
    public static CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        configuration.setAllowedOriginPatterns(List.of("http://localhost:*"));
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

Flutter端调整

1. 优化登录请求逻辑

添加401状态码处理,并开启withCredentials(如果后端使用Session认证):

Future<bool> loginAction(String nickname, String password) async {
  try {
    var dio = Dio(BaseOptions(
      baseUrl: "http://localhost:8081",
      headers: {"Accept": "application/json"},
      withCredentials: true, // Session认证需开启
    ));

    var param = {'nickname': nickname, 'password': password};

    Response response = await dio.post('/user/login', data: param);

    if (response.statusCode == 200) {
      final userId = response.data['user_id'];
      await storage.write(key: 'login', value: jsonEncode(userId));
      print('Login successful!');
      return true;
    } else if (response.statusCode == 401) {
      print('用户名或密码错误');
      return false;
    } else {
      print('Login failed: ${response.statusCode}');
      return false;
    }
  } catch (e) {
    print('Error occurred: $e');
    return false;
  }
}

通用排查步骤

  1. 查看请求详情:用浏览器DevTools或Flutter日志查看登录请求的完整响应头,确认302跳转目标
  2. 检查Spring Security配置:确认是否存在强制重定向规则(如successHandler、defaultSuccessUrl)
  3. 验证CORS配置:确保允许Flutter的域名/端口,且allowCredentials设置正确
  4. 独立测试接口:用Postman直接调用登录接口,验证是否返回预期JSON结果而非302

内容的提问来源于stack exchange,提问作者최수진

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.19 11:30:54