1. 项目概述Spring Security 3.2.9 双认证模式实战在构建现代Web应用时登录认证是绕不开的核心环节。尤其是在一些复杂的业务场景里比如一个后台管理系统既需要提供给内部管理员一个传统的表单登录页面进行日常操作又需要为移动端App或第三方服务提供一套基于Token的无状态API接口。如果两套认证体系各自为政不仅维护成本高安全策略也难以统一。这就是我们今天要解决的问题如何在一个Spring Security 3.2.9项目中优雅地同时支持Form表单登录和Token如JWT登录。Spring Security 3.2.9虽然不是一个非常新的版本但在许多遗留系统或对稳定性要求极高的生产环境中依然被广泛使用。它本身是一个功能强大但配置复杂的框架其核心是一系列过滤器Filter组成的责任链。默认情况下它倾向于只启用一种主要的认证方式。要实现“双模式”认证关键在于理解其过滤器链的运作机制并对其进行定制化改造让不同的请求路径能够被不同的“认证入口”所处理并且最终共享同一套用户数据源和权限校验逻辑。简单来说我们的目标就是当用户访问/admin/**这类管理页面时走传统的表单登录流程跳转到我们自定义的登录页登录成功后建立Session而当移动端请求/api/**接口时则要求它在HTTP Header中携带一个合法的JWT Token框架直接解析Token完成认证无需Session。下面我就结合自己多次在项目中整合的经验把核心思路、关键配置和那些容易踩的“坑”详细拆解一遍。2. 核心架构设计与思路拆解在动手写代码之前先把设计思路理清楚这能避免后期很多不必要的重构。Spring Security的认证过程本质上是由一系列AuthenticationProvider认证提供者和AuthenticationFilter认证过滤器协作完成的。我们的目标就是让这两套体系并行不悖。2.1 认证流程的并行化设计传统的表单登录核心是UsernamePasswordAuthenticationFilter它拦截默认的/loginPOST请求处理表单提交的用户名和密码。而Token认证这里以JWT为例通常需要一个自定义的过滤器比如JwtAuthenticationFilter来拦截请求从头信息中提取Token并进行验证。最直接的思路是配置两条独立的Spring Security过滤器链但这在同一个应用内比较麻烦且浪费资源。更优雅的方式是复用一条主过滤器链但通过配置让不同的请求路径匹配不同的认证入口。Spring Security的配置核心是HttpSecurity我们可以利用其antMatcher()方法对请求路径进行区分然后分别配置各自的认证规则。Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() // API接口通常禁用CSRF .authorizeRequests() .antMatchers(/api/public/**).permitAll() .antMatchers(/api/**).authenticated() // 所有/api/** 需要认证 .antMatchers(/admin/**).authenticated() // 所有/admin/** 需要认证 .anyRequest().permitAll() .and() // 为API路径配置JWT过滤器 .apply(new JwtTokenFilterConfigurer()) .and() // 为管理后台路径配置表单登录 .formLogin() .loginPage(/admin/login) // 自定义登录页 .loginProcessingUrl(/admin/doLogin) // 表单提交地址 .defaultSuccessUrl(/admin/index) .permitAll() .and() .logout() .logoutUrl(/admin/logout) .logoutSuccessUrl(/admin/login?logout) .permitAll(); }上面的配置是一个概念性的展示JwtTokenFilterConfigurer是一个我们自定义的配置类它的作用是将JwtAuthenticationFilter以正确的顺序插入到Spring Security的过滤器链中并且只对/api/**路径生效。而表单登录的相关配置则对/admin/**路径生效。这里的关键是执行顺序JWT过滤器需要在Spring Security默认的认证过滤器如BasicAuthenticationFilter之前执行。2.2 用户数据源的统一管理无论用户是通过表单提交的用户名密码还是通过JWT Token来访问最终都需要被转换成一个Authentication对象通常是UsernamePasswordAuthenticationToken并且这个对象需要包含用户的权限信息。这就要求背后有一个统一的用户数据查询服务。我们需要实现Spring Security核心的UserDetailsService接口。这个接口只有一个方法loadUserByUsername(String username)它的职责就是根据用户名从表单提交的username字段或JWT Token解析出的subject中获取加载出用户的详细信息包括密码、权限等。Service public class CustomUserDetailsService implements UserDetailsService { Autowired private UserRepository userRepository; // 你的用户数据DAO Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 从数据库或其他存储中查询用户实体 UserEntity user userRepository.findByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在: username); } // 2. 查询该用户的权限列表角色、菜单等 ListGrantedAuthority authorities getAuthoritiesByUserId(user.getId()); // 3. 返回Spring Security认识的UserDetails对象 return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), // 这里应该是加密后的密码 user.isEnabled(), true, true, true, // 账户状态 authorities ); } private ListGrantedAuthority getAuthoritiesByUserId(Long userId) { // 实现你的权限查询逻辑 // 例如return Arrays.asList(new SimpleGrantedAuthority(ROLE_ADMIN)); } }这个CustomUserDetailsService会被表单登录的DaoAuthenticationProvider和我们的JWT认证逻辑共同使用确保了用户信息来源的一致性。2.3 密码编码器的选择与配置Spring Security 3.2.9时期BCryptPasswordEncoder已经是公认的最佳实践。它采用加盐哈希每次加密结果都不同能有效抵御彩虹表攻击。我们需要在全局配置中声明它并注入到认证管理器中。Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Bean public PasswordEncoder passwordEncoder() { // 使用强度为10的BCrypt编码器 return new BCryptPasswordEncoder(10); } Bean Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(passwordEncoder()); // 关键将编码器与UserDetailsService绑定 } // ... 其他配置 }注意事项数据库里存储的密码必须是经过这个PasswordEncoder加密后的字符串。在用户注册或修改密码时必须调用passwordEncoder.encode(rawPassword)进行加密后再存入。表单登录时Spring Security会自动用相同的编码器对用户输入的密码进行编码然后与数据库存储的密文进行比对。3. 核心组件实现与配置详解有了顶层设计接下来我们深入各个核心组件的实现细节。这是项目能否成功运行的关键。3.1 JWT工具类的封装首先我们需要一个工具类来负责JWT Token的生成、解析和验证。这里我推荐使用jjwt这个库它API简洁功能完善。!-- pom.xml 依赖 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version !-- 注意与Spring Security 3.2.9兼容的版本 -- /dependencyComponent public class JwtTokenProvider { // 从配置文件中注入务必保密且足够复杂 Value(${jwt.secret-key}) private String secretKey; Value(${jwt.token-validity-in-seconds:3600}) private long tokenValidityInSeconds; // 生成Token public String createToken(String username, ListString roles) { Claims claims Jwts.claims().setSubject(username); claims.put(auth, roles.stream() .map(role - new SimpleGrantedAuthority(role)) .filter(Objects::nonNull) .collect(Collectors.toList())); Date now new Date(); Date validity new Date(now.getTime() tokenValidityInSeconds * 1000); return Jwts.builder() .setClaims(claims) .setIssuedAt(now) .setExpiration(validity) .signWith(SignatureAlgorithm.HS256, secretKey.getBytes(StandardCharsets.UTF_8)) .compact(); } // 从Token中获取用户名 public String getUsername(String token) { return Jwts.parser() .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody() .getSubject(); } // 验证Token有效性 public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)).parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { // 日志记录token无效的具体原因便于排查 log.warn(Invalid JWT token: {}, e.getMessage()); return false; } } // 从请求头中解析Token public String resolveToken(HttpServletRequest req) { String bearerToken req.getHeader(Authorization); if (bearerToken ! null bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); // 去掉Bearer 前缀 } return null; } }实操心得secretKey一定要足够长且复杂最好使用SecureRandom生成并存储在环境变量或配置服务器中绝不能硬编码在代码里。tokenValidityInSecondsToken有效期需要根据业务场景权衡太短影响体验太长增加安全风险通常1-24小时是常见范围。3.2 自定义JWT认证过滤器这是连接Spring Security过滤器链的桥梁。这个过滤器需要检查请求是否携带有效的JWT Token如果有效则手动构建一个Authentication对象并放入SecurityContextHolder这样后续的授权过滤器就能识别用户已登录。public class JwtAuthenticationFilter extends GenericFilterBean { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) { this.jwtTokenProvider jwtTokenProvider; this.userDetailsService userDetailsService; } Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; String token jwtTokenProvider.resolveToken(request); if (token ! null jwtTokenProvider.validateToken(token)) { try { String username jwtTokenProvider.getUsername(token); // 关键从统一的UserDetailsService加载用户信息确保权限是最新的 UserDetails userDetails this.userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 将认证信息设置到Security上下文中 SecurityContextHolder.getContext().setAuthentication(authentication); } catch (UsernameNotFoundException e) { // 用户可能已被删除Token无效 SecurityContextHolder.clearContext(); } } // 无论是否认证成功都继续执行过滤器链 filterChain.doFilter(req, res); } }关键点解析GenericFilterBeanSpring提供的通用过滤器基类方便获取Spring容器中的Bean。SecurityContextHolder这是Spring Security存储当前线程认证信息的地方。将Authentication对象设置进去就相当于告诉系统“这个用户已经登录了”。UserDetailsService这里再次调用loadUserByUsername是为了确保即使Token有效也能加载到用户最新的权限信息。如果用户角色在数据库中被修改下次携带旧Token请求时就能获得新权限。这是一种保守但安全的策略。如果追求极致性能且业务上权限不常变也可以将权限信息直接编码进Token但需要实现Token的强制刷新机制。3.3 过滤器配置器Configurer为了让我们的JwtAuthenticationFilter在正确的时机、对正确的路径生效我们需要实现一个SecurityConfigurerAdapter。这是Spring Security提供的高级定制方式。public class JwtTokenFilterConfigurer extends SecurityConfigurerAdapterDefaultSecurityFilterChain, HttpSecurity { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; public JwtTokenFilterConfigurer(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) { this.jwtTokenProvider jwtTokenProvider; this.userDetailsService userDetailsService; } Override public void configure(HttpSecurity http) throws Exception { JwtAuthenticationFilter customFilter new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService); // 将自定义过滤器添加到UsernamePasswordAuthenticationFilter之前 http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class); } }然后在主配置SecurityConfig中像之前概念代码那样使用.apply(new JwtTokenFilterConfigurer(...))。但这里有个大坑默认情况下这个过滤器会对所有请求生效。我们可能只希望它对/api/**路径生效。有几种解决方案在过滤器的doFilter方法开头判断请求路径如果不是API路径直接filterChain.doFilter。更Spring的方式是使用RequestMatcher。我们可以改造JwtAuthenticationFilter让它继承OncePerRequestFilter并重写shouldNotFilter方法。public class JwtAuthenticationFilter extends OncePerRequestFilter { // ... 其他字段和构造器 private AntPathRequestMatcher[] excludedMatchers { new AntPathRequestMatcher(/admin/**), new AntPathRequestMatcher(/login/**), new AntPathRequestMatcher(/static/**) }; Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { return Arrays.stream(excludedMatchers) .anyMatch(matcher - matcher.matches(request)); } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // ... 原有的doFilter逻辑 } }这样访问/admin等路径的请求就不会经过JWT过滤器从而顺利走到后面的表单登录过滤器链。4. 表单登录与API登录的实战配置现在我们把两部分配置完整地整合到一起并处理一些边界情况。4.1 完整的SecurityConfig配置类Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) // 启用方法级安全注解如PreAuthorize public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private CustomUserDetailsService customUserDetailsService; Autowired private JwtTokenProvider jwtTokenProvider; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(passwordEncoder()); } Bean Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } Override protected void configure(HttpSecurity http) throws Exception { http // 1. 会话管理对于API我们不需要Session对于后台需要。 .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 全局设为无状态主要影响API .and() // 2. 禁用CSRF因为API使用Token且Spring Security 3.x对CSRF的处理和后续版本有差异 .csrf().disable() // 3. 授权规则配置 .authorizeRequests() .antMatchers(/api/auth/login).permitAll() // Token获取接口公开 .antMatchers(/api/public/**).permitAll() .antMatchers(/api/**).authenticated() // 所有API接口需要认证通过Token .antMatchers(/admin/assets/**, /admin/login).permitAll() // 后台静态资源和登录页公开 .antMatchers(/admin/**).hasRole(ADMIN) // 后台管理路径需要ADMIN角色 .anyRequest().permitAll() .and() // 4. 应用JWT过滤器配置主要处理/api/** .apply(new JwtTokenFilterConfigurer(jwtTokenProvider, customUserDetailsService)) .and() // 5. 表单登录配置主要处理/admin/** .formLogin() .loginPage(/admin/login) // 自定义登录页面URL .loginProcessingUrl(/admin/doLogin) // 表单提交的URL .usernameParameter(username) // 表单用户名参数名 .passwordParameter(password) // 表单密码参数名 .defaultSuccessUrl(/admin/index, true) // 登录成功跳转 .failureUrl(/admin/login?errortrue) // 登录失败跳转 .permitAll() .and() // 6. 记住我功能可选仅针对表单登录 .rememberMe() .key(uniqueAndSecretKeyForRememberMe) // 必须设置一个key .tokenValiditySeconds(86400) // 记住我有效期单位秒 .and() // 7. 退出登录配置 .logout() .logoutUrl(/admin/logout) .logoutSuccessUrl(/admin/login?logout) .invalidateHttpSession(true) .deleteCookies(JSESSIONID, remember-me) .permitAll() // 8. 异常处理定义未认证和权限不足时的行为 .and() .exceptionHandling() .authenticationEntryPoint(new MyAuthenticationEntryPoint()) // 自定义未认证处理 .accessDeniedHandler(new MyAccessDeniedHandler()); // 自定义权限不足处理 } // 9. 静态资源放行避免被安全过滤器拦截 Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers(/css/**, /js/**, /images/**, /webjars/**); } }配置要点解析SessionCreationPolicy.STATELESS设置为无状态这意味着Spring Security不会创建和使用HttpSession。这对于纯API交互至关重要。但注意这不会影响后续的表单登录配置因为表单登录成功后的Authentication对象默认是存储在Session中的。这里设置为无状态主要是为了确保API请求不会意外创建Session。表单登录的Session管理由Servlet容器处理。CSRF在Spring Security 3.x中CSRF防护默认可能是关闭的但显式禁用是个好习惯。对于前后端分离的API使用Token如JWT本身就可以防御CSRF所以可以安全禁用。对于表单提交如果不禁用则需要确保表单中包含CSRF Token。.apply()这是插入自定义配置的关键。确保JWT过滤器的配置在表单登录配置之前或之后其实顺序很重要但因为我们通过shouldNotFilter或路径匹配做了隔离所以影响不大。通常放在前面让API请求尽早被处理。rememberMe()这是为表单登录提供的“记住我”功能基于Cookie实现。它和JWT Token是两套独立的持久化登录机制不要混淆。4.2 实现API登录接口签发Token表单登录由Spring Security的UsernamePasswordAuthenticationFilter自动处理了。但API的Token登录需要我们手动提供一个接口接收用户名密码验证后签发JWT Token。RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtTokenProvider jwtTokenProvider; Autowired private UserDetailsService userDetailsService; PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest loginRequest) { try { // 1. 使用Spring Security的AuthenticationManager进行认证 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 2. 认证成功设置安全上下文可选对于纯API登录通常不设置 // SecurityContextHolder.getContext().setAuthentication(authentication); // 3. 加载用户详细信息以获取角色 UserDetails userDetails userDetailsService.loadUserByUsername(loginRequest.getUsername()); ListString roles userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); // 4. 生成JWT Token String token jwtTokenProvider.createToken(loginRequest.getUsername(), roles); // 5. 返回Token通常放在响应体也可以放在Header MapString, Object response new HashMap(); response.put(username, loginRequest.getUsername()); response.put(token, token); response.put(expiresIn, jwtTokenProvider.getTokenValidityInSeconds()); // 假设工具类提供了这个方法 return ResponseEntity.ok(response); } catch (BadCredentialsException e) { // 用户名或密码错误 return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Invalid username or password); } catch (DisabledException | LockedException e) { // 账户被禁用或锁定 return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Account is disabled or locked); } catch (Exception e) { // 其他异常 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Login failed); } } // 简单的登录请求DTO public static class LoginRequest { private String username; private String password; // getters and setters } }注意事项这个登录接口本身是公开的permitAll它接收JSON格式的用户名密码。认证成功后它返回一个JWT Token。客户端如移动端后续访问其他受保护的API时需要在HTTP Header中加上Authorization: Bearer token。4.3 自定义认证入口和拒绝处理器当未经认证的用户访问受保护资源或者认证用户访问权限不足的资源时我们需要返回统一的JSON格式错误信息而不是Spring Security默认的跳转登录页或403页面。Component public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { // 判断请求是否来自API根据路径或Header如X-Requested-With: XMLHttpRequest if (isApiRequest(request)) { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); MapString, Object body new HashMap(); body.put(status, HttpServletResponse.SC_UNAUTHORIZED); body.put(error, Unauthorized); body.put(message, authException.getMessage()); body.put(path, request.getServletPath()); ObjectMapper mapper new ObjectMapper(); mapper.writeValue(response.getOutputStream(), body); } else { // 对于Web页面请求重定向到登录页保持默认行为或自定义 response.sendRedirect(/admin/login); } } private boolean isApiRequest(HttpServletRequest request) { return request.getRequestURI().startsWith(/api/) || XMLHttpRequest.equalsIgnoreCase(request.getHeader(X-Requested-With)); } } Component public class MyAccessDeniedHandler implements AccessDeniedHandler { Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { if (isApiRequest(request)) { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setStatus(HttpServletResponse.SC_FORBIDDEN); MapString, Object body new HashMap(); body.put(status, HttpServletResponse.SC_FORBIDDEN); body.put(error, Forbidden); body.put(message, Access Denied: accessDeniedException.getMessage()); body.put(path, request.getServletPath()); ObjectMapper mapper new ObjectMapper(); mapper.writeValue(response.getOutputStream(), body); } else { // 对于Web页面可以返回一个错误页面 request.getRequestDispatcher(/error/403).forward(request, response); } } // ... isApiRequest 方法同上 }这样配置后API请求在认证失败时会收到清晰的JSON错误而浏览器访问后台页面时依然会跳转到登录页体验更佳。5. 常见问题、排查技巧与进阶优化在实际部署和运行中你肯定会遇到各种各样的问题。下面我整理了一些典型场景和解决方案。5.1 问题排查速查表问题现象可能原因排查步骤与解决方案表单登录成功但总是跳回登录页1. Session未正确创建或丢失。2. CSRF保护未禁用或Token未提交。3. 登录成功后的跳转路径被安全规则拦截。1. 检查服务器Session配置如Tomcat的context.xml。检查SecurityConfig中是否误配了sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)这会导致不创建Session。对于表单登录应该用IF_REQUIRED默认或ALWAYS。2. 检查表单是否提交了CSRF Token。如果不想用确保在HttpSecurity配置中为表单登录路径禁用了CSRF.csrf().ignoringAntMatchers(/admin/doLogin)。3. 使用浏览器开发者工具查看登录POST请求后的响应和重定向。检查重定向到的目标URL如/admin/index是否在安全规则中允许访问。API接口返回403 Forbidden但Token有效1. Token解析出的用户权限不足。2. 方法级安全注解如PreAuthorize限制了访问。3.JwtAuthenticationFilter中设置的Authentication对象权限列表为空。1. 在JwtAuthenticationFilter中打印出从Token解析出的用户名和从UserDetailsService加载到的权限确认是否正确。2. 检查控制器方法或Service方法上的PreAuthorize(hasRole(XXX))注解确认当前用户角色是否匹配。3. 确保数据库中的用户权限数据正确并且UserDetailsService的getAuthoritiesByUserId方法正确返回了GrantedAuthority列表。JWT Token认证不生效请求仍被重定向到登录页1.JwtAuthenticationFilter未生效顺序不对或路径被排除。2. Token解析失败密钥不匹配、已过期、格式错误。3. 请求未携带Token或Header格式错误。1. 在JwtAuthenticationFilter的doFilterInternal方法开始处加日志确认过滤器是否被执行。检查shouldNotFilter逻辑是否错误地排除了API路径。2. 在JwtTokenProvider.validateToken方法中捕获异常并打印详细日志。确认生成和验证Token使用的是同一个secretKey。检查服务器时间是否准确Token可能已过期。3. 使用Postman等工具模拟请求确认AuthorizationHeader的格式是Bearer token且中间有空格。同时开启表单和Token登录后性能下降1. 每次API请求都查询数据库加载用户权限。2.UserDetailsService实现效率低。1.优化策略将用户权限信息缓存在Redis中。在JwtAuthenticationFilter中先从缓存查权限缓存不存在再查库并回填缓存。注意设置合理的缓存过期时间并在用户权限变更时清除缓存。2. 确保UserDetailsService中的数据库查询是高效的比如使用了索引。避免N1查询问题。登录接口/api/auth/login报4041. 控制器路径映射错误。2. 该路径被Spring Security拦截。1. 检查AuthController的RequestMapping注解和login方法的PostMapping注解路径是否正确拼接。2. 确认在SecurityConfig的configure(HttpSecurity)中/api/auth/login路径已被.permitAll()。同时检查是否有任何全局的Servlet Filter或Interceptor拦截了该路径。5.2 安全加固与进阶优化Token刷新机制JWT Token一旦签发在有效期内无法废止。为了实现类似“退出登录”使Token失效的功能可以引入Token黑名单或使用刷新TokenRefresh Token机制。访问TokenAccess Token有效期设置较短如30分钟同时签发一个有效期较长的刷新Token如7天。当Access Token过期后客户端用Refresh Token换取新的Access Token。服务端可以将已注销用户的Refresh Token加入黑名单存储于Redis从而实现安全的登出。防止Token盗用在JWT的Payload中加入一些客户端指纹信息如IP地址、User-Agent的哈希值。验证Token时同时校验这些指纹是否与当前请求匹配。但这会降低Token的通用性比如用户切换网络后IP变化。分布式会话考虑虽然API是无状态的但表单登录的Session默认存储在应用服务器的内存中。在集群部署时需要将会话外部化存储例如使用Spring Session集成Redis实现Session的共享。监控与审计在JwtAuthenticationFilter和登录成功/失败的处理逻辑中加入审计日志记录用户的登录时间、IP、Token签发和验证情况便于安全事件追溯。整合OAuth2可选如果你的系统还需要支持第三方登录如微信、GitHub可以考虑在现有架构上整合Spring Security OAuth2。这相当于在表单登录和JWT登录之外增加了第三套认证流程。OAuth2的授权码模式通常用于Web应用其OAuth2LoginConfigurer可以和你现有的表单登录配置共存通过不同的请求路径如/oauth2/authorization/github来区分。整个项目搭建下来最深的体会就是Spring Security的强大在于其高度可配置的过滤器链和清晰的抽象如AuthenticationProvider,UserDetailsService。实现双认证模式的核心不是去对抗这个框架而是理解其工作原理然后像搭积木一样把表单登录和JWT认证这两块“积木”以恰当的方式嵌入到过滤器链的合适位置并确保它们共享底层的用户和权限数据。过程中一定要善用日志调试从HTTP请求进入过滤器链开始一步步跟踪理清每个过滤器的责任问题往往就迎刃而解了。
Spring Security 3.2.9双认证模式实战:表单登录与JWT Token集成
发布时间:2026/6/16 4:07:04
1. 项目概述Spring Security 3.2.9 双认证模式实战在构建现代Web应用时登录认证是绕不开的核心环节。尤其是在一些复杂的业务场景里比如一个后台管理系统既需要提供给内部管理员一个传统的表单登录页面进行日常操作又需要为移动端App或第三方服务提供一套基于Token的无状态API接口。如果两套认证体系各自为政不仅维护成本高安全策略也难以统一。这就是我们今天要解决的问题如何在一个Spring Security 3.2.9项目中优雅地同时支持Form表单登录和Token如JWT登录。Spring Security 3.2.9虽然不是一个非常新的版本但在许多遗留系统或对稳定性要求极高的生产环境中依然被广泛使用。它本身是一个功能强大但配置复杂的框架其核心是一系列过滤器Filter组成的责任链。默认情况下它倾向于只启用一种主要的认证方式。要实现“双模式”认证关键在于理解其过滤器链的运作机制并对其进行定制化改造让不同的请求路径能够被不同的“认证入口”所处理并且最终共享同一套用户数据源和权限校验逻辑。简单来说我们的目标就是当用户访问/admin/**这类管理页面时走传统的表单登录流程跳转到我们自定义的登录页登录成功后建立Session而当移动端请求/api/**接口时则要求它在HTTP Header中携带一个合法的JWT Token框架直接解析Token完成认证无需Session。下面我就结合自己多次在项目中整合的经验把核心思路、关键配置和那些容易踩的“坑”详细拆解一遍。2. 核心架构设计与思路拆解在动手写代码之前先把设计思路理清楚这能避免后期很多不必要的重构。Spring Security的认证过程本质上是由一系列AuthenticationProvider认证提供者和AuthenticationFilter认证过滤器协作完成的。我们的目标就是让这两套体系并行不悖。2.1 认证流程的并行化设计传统的表单登录核心是UsernamePasswordAuthenticationFilter它拦截默认的/loginPOST请求处理表单提交的用户名和密码。而Token认证这里以JWT为例通常需要一个自定义的过滤器比如JwtAuthenticationFilter来拦截请求从头信息中提取Token并进行验证。最直接的思路是配置两条独立的Spring Security过滤器链但这在同一个应用内比较麻烦且浪费资源。更优雅的方式是复用一条主过滤器链但通过配置让不同的请求路径匹配不同的认证入口。Spring Security的配置核心是HttpSecurity我们可以利用其antMatcher()方法对请求路径进行区分然后分别配置各自的认证规则。Override protected void configure(HttpSecurity http) throws Exception { http .csrf().disable() // API接口通常禁用CSRF .authorizeRequests() .antMatchers(/api/public/**).permitAll() .antMatchers(/api/**).authenticated() // 所有/api/** 需要认证 .antMatchers(/admin/**).authenticated() // 所有/admin/** 需要认证 .anyRequest().permitAll() .and() // 为API路径配置JWT过滤器 .apply(new JwtTokenFilterConfigurer()) .and() // 为管理后台路径配置表单登录 .formLogin() .loginPage(/admin/login) // 自定义登录页 .loginProcessingUrl(/admin/doLogin) // 表单提交地址 .defaultSuccessUrl(/admin/index) .permitAll() .and() .logout() .logoutUrl(/admin/logout) .logoutSuccessUrl(/admin/login?logout) .permitAll(); }上面的配置是一个概念性的展示JwtTokenFilterConfigurer是一个我们自定义的配置类它的作用是将JwtAuthenticationFilter以正确的顺序插入到Spring Security的过滤器链中并且只对/api/**路径生效。而表单登录的相关配置则对/admin/**路径生效。这里的关键是执行顺序JWT过滤器需要在Spring Security默认的认证过滤器如BasicAuthenticationFilter之前执行。2.2 用户数据源的统一管理无论用户是通过表单提交的用户名密码还是通过JWT Token来访问最终都需要被转换成一个Authentication对象通常是UsernamePasswordAuthenticationToken并且这个对象需要包含用户的权限信息。这就要求背后有一个统一的用户数据查询服务。我们需要实现Spring Security核心的UserDetailsService接口。这个接口只有一个方法loadUserByUsername(String username)它的职责就是根据用户名从表单提交的username字段或JWT Token解析出的subject中获取加载出用户的详细信息包括密码、权限等。Service public class CustomUserDetailsService implements UserDetailsService { Autowired private UserRepository userRepository; // 你的用户数据DAO Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 1. 从数据库或其他存储中查询用户实体 UserEntity user userRepository.findByUsername(username); if (user null) { throw new UsernameNotFoundException(用户不存在: username); } // 2. 查询该用户的权限列表角色、菜单等 ListGrantedAuthority authorities getAuthoritiesByUserId(user.getId()); // 3. 返回Spring Security认识的UserDetails对象 return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPassword(), // 这里应该是加密后的密码 user.isEnabled(), true, true, true, // 账户状态 authorities ); } private ListGrantedAuthority getAuthoritiesByUserId(Long userId) { // 实现你的权限查询逻辑 // 例如return Arrays.asList(new SimpleGrantedAuthority(ROLE_ADMIN)); } }这个CustomUserDetailsService会被表单登录的DaoAuthenticationProvider和我们的JWT认证逻辑共同使用确保了用户信息来源的一致性。2.3 密码编码器的选择与配置Spring Security 3.2.9时期BCryptPasswordEncoder已经是公认的最佳实践。它采用加盐哈希每次加密结果都不同能有效抵御彩虹表攻击。我们需要在全局配置中声明它并注入到认证管理器中。Configuration EnableWebSecurity public class SecurityConfig extends WebSecurityConfigurerAdapter { Bean public PasswordEncoder passwordEncoder() { // 使用强度为10的BCrypt编码器 return new BCryptPasswordEncoder(10); } Bean Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(passwordEncoder()); // 关键将编码器与UserDetailsService绑定 } // ... 其他配置 }注意事项数据库里存储的密码必须是经过这个PasswordEncoder加密后的字符串。在用户注册或修改密码时必须调用passwordEncoder.encode(rawPassword)进行加密后再存入。表单登录时Spring Security会自动用相同的编码器对用户输入的密码进行编码然后与数据库存储的密文进行比对。3. 核心组件实现与配置详解有了顶层设计接下来我们深入各个核心组件的实现细节。这是项目能否成功运行的关键。3.1 JWT工具类的封装首先我们需要一个工具类来负责JWT Token的生成、解析和验证。这里我推荐使用jjwt这个库它API简洁功能完善。!-- pom.xml 依赖 -- dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt/artifactId version0.9.1/version !-- 注意与Spring Security 3.2.9兼容的版本 -- /dependencyComponent public class JwtTokenProvider { // 从配置文件中注入务必保密且足够复杂 Value(${jwt.secret-key}) private String secretKey; Value(${jwt.token-validity-in-seconds:3600}) private long tokenValidityInSeconds; // 生成Token public String createToken(String username, ListString roles) { Claims claims Jwts.claims().setSubject(username); claims.put(auth, roles.stream() .map(role - new SimpleGrantedAuthority(role)) .filter(Objects::nonNull) .collect(Collectors.toList())); Date now new Date(); Date validity new Date(now.getTime() tokenValidityInSeconds * 1000); return Jwts.builder() .setClaims(claims) .setIssuedAt(now) .setExpiration(validity) .signWith(SignatureAlgorithm.HS256, secretKey.getBytes(StandardCharsets.UTF_8)) .compact(); } // 从Token中获取用户名 public String getUsername(String token) { return Jwts.parser() .setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)) .parseClaimsJws(token) .getBody() .getSubject(); } // 验证Token有效性 public boolean validateToken(String token) { try { Jwts.parser().setSigningKey(secretKey.getBytes(StandardCharsets.UTF_8)).parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { // 日志记录token无效的具体原因便于排查 log.warn(Invalid JWT token: {}, e.getMessage()); return false; } } // 从请求头中解析Token public String resolveToken(HttpServletRequest req) { String bearerToken req.getHeader(Authorization); if (bearerToken ! null bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); // 去掉Bearer 前缀 } return null; } }实操心得secretKey一定要足够长且复杂最好使用SecureRandom生成并存储在环境变量或配置服务器中绝不能硬编码在代码里。tokenValidityInSecondsToken有效期需要根据业务场景权衡太短影响体验太长增加安全风险通常1-24小时是常见范围。3.2 自定义JWT认证过滤器这是连接Spring Security过滤器链的桥梁。这个过滤器需要检查请求是否携带有效的JWT Token如果有效则手动构建一个Authentication对象并放入SecurityContextHolder这样后续的授权过滤器就能识别用户已登录。public class JwtAuthenticationFilter extends GenericFilterBean { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) { this.jwtTokenProvider jwtTokenProvider; this.userDetailsService userDetailsService; } Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain filterChain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; String token jwtTokenProvider.resolveToken(request); if (token ! null jwtTokenProvider.validateToken(token)) { try { String username jwtTokenProvider.getUsername(token); // 关键从统一的UserDetailsService加载用户信息确保权限是最新的 UserDetails userDetails this.userDetailsService.loadUserByUsername(username); UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // 将认证信息设置到Security上下文中 SecurityContextHolder.getContext().setAuthentication(authentication); } catch (UsernameNotFoundException e) { // 用户可能已被删除Token无效 SecurityContextHolder.clearContext(); } } // 无论是否认证成功都继续执行过滤器链 filterChain.doFilter(req, res); } }关键点解析GenericFilterBeanSpring提供的通用过滤器基类方便获取Spring容器中的Bean。SecurityContextHolder这是Spring Security存储当前线程认证信息的地方。将Authentication对象设置进去就相当于告诉系统“这个用户已经登录了”。UserDetailsService这里再次调用loadUserByUsername是为了确保即使Token有效也能加载到用户最新的权限信息。如果用户角色在数据库中被修改下次携带旧Token请求时就能获得新权限。这是一种保守但安全的策略。如果追求极致性能且业务上权限不常变也可以将权限信息直接编码进Token但需要实现Token的强制刷新机制。3.3 过滤器配置器Configurer为了让我们的JwtAuthenticationFilter在正确的时机、对正确的路径生效我们需要实现一个SecurityConfigurerAdapter。这是Spring Security提供的高级定制方式。public class JwtTokenFilterConfigurer extends SecurityConfigurerAdapterDefaultSecurityFilterChain, HttpSecurity { private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; public JwtTokenFilterConfigurer(JwtTokenProvider jwtTokenProvider, UserDetailsService userDetailsService) { this.jwtTokenProvider jwtTokenProvider; this.userDetailsService userDetailsService; } Override public void configure(HttpSecurity http) throws Exception { JwtAuthenticationFilter customFilter new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService); // 将自定义过滤器添加到UsernamePasswordAuthenticationFilter之前 http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class); } }然后在主配置SecurityConfig中像之前概念代码那样使用.apply(new JwtTokenFilterConfigurer(...))。但这里有个大坑默认情况下这个过滤器会对所有请求生效。我们可能只希望它对/api/**路径生效。有几种解决方案在过滤器的doFilter方法开头判断请求路径如果不是API路径直接filterChain.doFilter。更Spring的方式是使用RequestMatcher。我们可以改造JwtAuthenticationFilter让它继承OncePerRequestFilter并重写shouldNotFilter方法。public class JwtAuthenticationFilter extends OncePerRequestFilter { // ... 其他字段和构造器 private AntPathRequestMatcher[] excludedMatchers { new AntPathRequestMatcher(/admin/**), new AntPathRequestMatcher(/login/**), new AntPathRequestMatcher(/static/**) }; Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { return Arrays.stream(excludedMatchers) .anyMatch(matcher - matcher.matches(request)); } Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // ... 原有的doFilter逻辑 } }这样访问/admin等路径的请求就不会经过JWT过滤器从而顺利走到后面的表单登录过滤器链。4. 表单登录与API登录的实战配置现在我们把两部分配置完整地整合到一起并处理一些边界情况。4.1 完整的SecurityConfig配置类Configuration EnableWebSecurity EnableGlobalMethodSecurity(prePostEnabled true) // 启用方法级安全注解如PreAuthorize public class SecurityConfig extends WebSecurityConfigurerAdapter { Autowired private CustomUserDetailsService customUserDetailsService; Autowired private JwtTokenProvider jwtTokenProvider; Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.userDetailsService(customUserDetailsService) .passwordEncoder(passwordEncoder()); } Bean Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); } Override protected void configure(HttpSecurity http) throws Exception { http // 1. 会话管理对于API我们不需要Session对于后台需要。 .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 全局设为无状态主要影响API .and() // 2. 禁用CSRF因为API使用Token且Spring Security 3.x对CSRF的处理和后续版本有差异 .csrf().disable() // 3. 授权规则配置 .authorizeRequests() .antMatchers(/api/auth/login).permitAll() // Token获取接口公开 .antMatchers(/api/public/**).permitAll() .antMatchers(/api/**).authenticated() // 所有API接口需要认证通过Token .antMatchers(/admin/assets/**, /admin/login).permitAll() // 后台静态资源和登录页公开 .antMatchers(/admin/**).hasRole(ADMIN) // 后台管理路径需要ADMIN角色 .anyRequest().permitAll() .and() // 4. 应用JWT过滤器配置主要处理/api/** .apply(new JwtTokenFilterConfigurer(jwtTokenProvider, customUserDetailsService)) .and() // 5. 表单登录配置主要处理/admin/** .formLogin() .loginPage(/admin/login) // 自定义登录页面URL .loginProcessingUrl(/admin/doLogin) // 表单提交的URL .usernameParameter(username) // 表单用户名参数名 .passwordParameter(password) // 表单密码参数名 .defaultSuccessUrl(/admin/index, true) // 登录成功跳转 .failureUrl(/admin/login?errortrue) // 登录失败跳转 .permitAll() .and() // 6. 记住我功能可选仅针对表单登录 .rememberMe() .key(uniqueAndSecretKeyForRememberMe) // 必须设置一个key .tokenValiditySeconds(86400) // 记住我有效期单位秒 .and() // 7. 退出登录配置 .logout() .logoutUrl(/admin/logout) .logoutSuccessUrl(/admin/login?logout) .invalidateHttpSession(true) .deleteCookies(JSESSIONID, remember-me) .permitAll() // 8. 异常处理定义未认证和权限不足时的行为 .and() .exceptionHandling() .authenticationEntryPoint(new MyAuthenticationEntryPoint()) // 自定义未认证处理 .accessDeniedHandler(new MyAccessDeniedHandler()); // 自定义权限不足处理 } // 9. 静态资源放行避免被安全过滤器拦截 Override public void configure(WebSecurity web) throws Exception { web.ignoring().antMatchers(/css/**, /js/**, /images/**, /webjars/**); } }配置要点解析SessionCreationPolicy.STATELESS设置为无状态这意味着Spring Security不会创建和使用HttpSession。这对于纯API交互至关重要。但注意这不会影响后续的表单登录配置因为表单登录成功后的Authentication对象默认是存储在Session中的。这里设置为无状态主要是为了确保API请求不会意外创建Session。表单登录的Session管理由Servlet容器处理。CSRF在Spring Security 3.x中CSRF防护默认可能是关闭的但显式禁用是个好习惯。对于前后端分离的API使用Token如JWT本身就可以防御CSRF所以可以安全禁用。对于表单提交如果不禁用则需要确保表单中包含CSRF Token。.apply()这是插入自定义配置的关键。确保JWT过滤器的配置在表单登录配置之前或之后其实顺序很重要但因为我们通过shouldNotFilter或路径匹配做了隔离所以影响不大。通常放在前面让API请求尽早被处理。rememberMe()这是为表单登录提供的“记住我”功能基于Cookie实现。它和JWT Token是两套独立的持久化登录机制不要混淆。4.2 实现API登录接口签发Token表单登录由Spring Security的UsernamePasswordAuthenticationFilter自动处理了。但API的Token登录需要我们手动提供一个接口接收用户名密码验证后签发JWT Token。RestController RequestMapping(/api/auth) public class AuthController { Autowired private AuthenticationManager authenticationManager; Autowired private JwtTokenProvider jwtTokenProvider; Autowired private UserDetailsService userDetailsService; PostMapping(/login) public ResponseEntity? login(RequestBody LoginRequest loginRequest) { try { // 1. 使用Spring Security的AuthenticationManager进行认证 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword() ) ); // 2. 认证成功设置安全上下文可选对于纯API登录通常不设置 // SecurityContextHolder.getContext().setAuthentication(authentication); // 3. 加载用户详细信息以获取角色 UserDetails userDetails userDetailsService.loadUserByUsername(loginRequest.getUsername()); ListString roles userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority) .collect(Collectors.toList()); // 4. 生成JWT Token String token jwtTokenProvider.createToken(loginRequest.getUsername(), roles); // 5. 返回Token通常放在响应体也可以放在Header MapString, Object response new HashMap(); response.put(username, loginRequest.getUsername()); response.put(token, token); response.put(expiresIn, jwtTokenProvider.getTokenValidityInSeconds()); // 假设工具类提供了这个方法 return ResponseEntity.ok(response); } catch (BadCredentialsException e) { // 用户名或密码错误 return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(Invalid username or password); } catch (DisabledException | LockedException e) { // 账户被禁用或锁定 return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Account is disabled or locked); } catch (Exception e) { // 其他异常 return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Login failed); } } // 简单的登录请求DTO public static class LoginRequest { private String username; private String password; // getters and setters } }注意事项这个登录接口本身是公开的permitAll它接收JSON格式的用户名密码。认证成功后它返回一个JWT Token。客户端如移动端后续访问其他受保护的API时需要在HTTP Header中加上Authorization: Bearer token。4.3 自定义认证入口和拒绝处理器当未经认证的用户访问受保护资源或者认证用户访问权限不足的资源时我们需要返回统一的JSON格式错误信息而不是Spring Security默认的跳转登录页或403页面。Component public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { // 判断请求是否来自API根据路径或Header如X-Requested-With: XMLHttpRequest if (isApiRequest(request)) { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); MapString, Object body new HashMap(); body.put(status, HttpServletResponse.SC_UNAUTHORIZED); body.put(error, Unauthorized); body.put(message, authException.getMessage()); body.put(path, request.getServletPath()); ObjectMapper mapper new ObjectMapper(); mapper.writeValue(response.getOutputStream(), body); } else { // 对于Web页面请求重定向到登录页保持默认行为或自定义 response.sendRedirect(/admin/login); } } private boolean isApiRequest(HttpServletRequest request) { return request.getRequestURI().startsWith(/api/) || XMLHttpRequest.equalsIgnoreCase(request.getHeader(X-Requested-With)); } } Component public class MyAccessDeniedHandler implements AccessDeniedHandler { Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { if (isApiRequest(request)) { response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setStatus(HttpServletResponse.SC_FORBIDDEN); MapString, Object body new HashMap(); body.put(status, HttpServletResponse.SC_FORBIDDEN); body.put(error, Forbidden); body.put(message, Access Denied: accessDeniedException.getMessage()); body.put(path, request.getServletPath()); ObjectMapper mapper new ObjectMapper(); mapper.writeValue(response.getOutputStream(), body); } else { // 对于Web页面可以返回一个错误页面 request.getRequestDispatcher(/error/403).forward(request, response); } } // ... isApiRequest 方法同上 }这样配置后API请求在认证失败时会收到清晰的JSON错误而浏览器访问后台页面时依然会跳转到登录页体验更佳。5. 常见问题、排查技巧与进阶优化在实际部署和运行中你肯定会遇到各种各样的问题。下面我整理了一些典型场景和解决方案。5.1 问题排查速查表问题现象可能原因排查步骤与解决方案表单登录成功但总是跳回登录页1. Session未正确创建或丢失。2. CSRF保护未禁用或Token未提交。3. 登录成功后的跳转路径被安全规则拦截。1. 检查服务器Session配置如Tomcat的context.xml。检查SecurityConfig中是否误配了sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)这会导致不创建Session。对于表单登录应该用IF_REQUIRED默认或ALWAYS。2. 检查表单是否提交了CSRF Token。如果不想用确保在HttpSecurity配置中为表单登录路径禁用了CSRF.csrf().ignoringAntMatchers(/admin/doLogin)。3. 使用浏览器开发者工具查看登录POST请求后的响应和重定向。检查重定向到的目标URL如/admin/index是否在安全规则中允许访问。API接口返回403 Forbidden但Token有效1. Token解析出的用户权限不足。2. 方法级安全注解如PreAuthorize限制了访问。3.JwtAuthenticationFilter中设置的Authentication对象权限列表为空。1. 在JwtAuthenticationFilter中打印出从Token解析出的用户名和从UserDetailsService加载到的权限确认是否正确。2. 检查控制器方法或Service方法上的PreAuthorize(hasRole(XXX))注解确认当前用户角色是否匹配。3. 确保数据库中的用户权限数据正确并且UserDetailsService的getAuthoritiesByUserId方法正确返回了GrantedAuthority列表。JWT Token认证不生效请求仍被重定向到登录页1.JwtAuthenticationFilter未生效顺序不对或路径被排除。2. Token解析失败密钥不匹配、已过期、格式错误。3. 请求未携带Token或Header格式错误。1. 在JwtAuthenticationFilter的doFilterInternal方法开始处加日志确认过滤器是否被执行。检查shouldNotFilter逻辑是否错误地排除了API路径。2. 在JwtTokenProvider.validateToken方法中捕获异常并打印详细日志。确认生成和验证Token使用的是同一个secretKey。检查服务器时间是否准确Token可能已过期。3. 使用Postman等工具模拟请求确认AuthorizationHeader的格式是Bearer token且中间有空格。同时开启表单和Token登录后性能下降1. 每次API请求都查询数据库加载用户权限。2.UserDetailsService实现效率低。1.优化策略将用户权限信息缓存在Redis中。在JwtAuthenticationFilter中先从缓存查权限缓存不存在再查库并回填缓存。注意设置合理的缓存过期时间并在用户权限变更时清除缓存。2. 确保UserDetailsService中的数据库查询是高效的比如使用了索引。避免N1查询问题。登录接口/api/auth/login报4041. 控制器路径映射错误。2. 该路径被Spring Security拦截。1. 检查AuthController的RequestMapping注解和login方法的PostMapping注解路径是否正确拼接。2. 确认在SecurityConfig的configure(HttpSecurity)中/api/auth/login路径已被.permitAll()。同时检查是否有任何全局的Servlet Filter或Interceptor拦截了该路径。5.2 安全加固与进阶优化Token刷新机制JWT Token一旦签发在有效期内无法废止。为了实现类似“退出登录”使Token失效的功能可以引入Token黑名单或使用刷新TokenRefresh Token机制。访问TokenAccess Token有效期设置较短如30分钟同时签发一个有效期较长的刷新Token如7天。当Access Token过期后客户端用Refresh Token换取新的Access Token。服务端可以将已注销用户的Refresh Token加入黑名单存储于Redis从而实现安全的登出。防止Token盗用在JWT的Payload中加入一些客户端指纹信息如IP地址、User-Agent的哈希值。验证Token时同时校验这些指纹是否与当前请求匹配。但这会降低Token的通用性比如用户切换网络后IP变化。分布式会话考虑虽然API是无状态的但表单登录的Session默认存储在应用服务器的内存中。在集群部署时需要将会话外部化存储例如使用Spring Session集成Redis实现Session的共享。监控与审计在JwtAuthenticationFilter和登录成功/失败的处理逻辑中加入审计日志记录用户的登录时间、IP、Token签发和验证情况便于安全事件追溯。整合OAuth2可选如果你的系统还需要支持第三方登录如微信、GitHub可以考虑在现有架构上整合Spring Security OAuth2。这相当于在表单登录和JWT登录之外增加了第三套认证流程。OAuth2的授权码模式通常用于Web应用其OAuth2LoginConfigurer可以和你现有的表单登录配置共存通过不同的请求路径如/oauth2/authorization/github来区分。整个项目搭建下来最深的体会就是Spring Security的强大在于其高度可配置的过滤器链和清晰的抽象如AuthenticationProvider,UserDetailsService。实现双认证模式的核心不是去对抗这个框架而是理解其工作原理然后像搭积木一样把表单登录和JWT认证这两块“积木”以恰当的方式嵌入到过滤器链的合适位置并确保它们共享底层的用户和权限数据。过程中一定要善用日志调试从HTTP请求进入过滤器链开始一步步跟踪理清每个过滤器的责任问题往往就迎刃而解了。