Spring Security认证失败URL首次跳转异常及解决
问题场景
为区分凭证错误和账户未激活两种认证失败场景,配置不同跳转URL,我自定义了AuthenticationProvider和AuthenticationFailureHandler,但遇到首次认证失败跳转异常的问题:
- 访问主页
localhost:8080,因未认证被重定向到localhost:8080/login - 输入错误凭证,本该跳转到
localhost:8080/login?badCredentials,却停留在localhost:8080/login - 输入正确凭证,被重定向到
localhost:8080/login?badCredentials&continue,但实际已完成登录(Authentication对象已存入SecurityContext) - 后续再输入错误凭证或未激活账户时,跳转恢复正常,仅首次出现问题
- 手动访问主页可正常进入登录状态
相关代码
AuthenticationProvider实现类
@Component public class AuthenticationProviderImpl implements AuthenticationProvider { private final UserDetailsService userDetailsService; private final PasswordEncoder passwordEncoder; @Autowired public AuthenticationProviderImpl(UserDetailsService userDetailsService, PasswordEncoder passwordEncoder) { this.userDetailsService = userDetailsService; this.passwordEncoder = passwordEncoder; } @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { try { String passwordEnteredByUser = authentication.getCredentials().toString(); UserDetails userDetails = userDetailsService.loadUserByUsername(authentication.getName()); if (!passwordEncoder.matches(passwordEnteredByUser, userDetails.getPassword())) { throw new BadCredentialsException("Bad credentials"); } if (!userDetails.isEnabled()) { throw new AccountNotActivatedException("Account not activated"); } return new UsernamePasswordAuthenticationToken( userDetails.getUsername(), userDetails.getPassword(), userDetails.getAuthorities() ); } catch (UsernameNotFoundException e) { throw new BadCredentialsException("Bad credentials"); } } @Override public boolean supports(Class<?> authentication) { return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); } }
AuthenticationFailureHandler实现类
@Setter @Component public class AuthenticationFailureHandlerImpl implements AuthenticationFailureHandler { private String redirectUrl = "login?error"; private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy(); @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { if (exception instanceof BadCredentialsException) { setRedirectUrl("login?badCredentials"); } else if (exception instanceof AccountNotActivatedException) { setRedirectUrl("login?notActivated"); } this.redirectStrategy.sendRedirect(request, response, redirectUrl); } }
SecurityConfig配置类
@Configuration public class SecurityConfig { private final AuthenticationFailureHandler authenticationFailureHandler; private final AuthenticationProvider authenticationProvider; @Autowired public SecurityConfig(AuthenticationProvider authenticationProvider, AuthenticationFailureHandler authenticationFailureHandler) { this.authenticationProvider = authenticationProvider; this.authenticationFailureHandler = authenticationFailureHandler; } @Bean public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { return httpSecurity .authorizeHttpRequests(authorize -> authorize .requestMatchers("/register").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form .permitAll() .loginPage("/login") .defaultSuccessUrl("/") .failureHandler(authenticationFailureHandler) ) .logout(logout -> logout .logoutUrl("/logout") ) .authenticationProvider(authenticationProvider) .build(); } @Bean public static PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }
解决方案
问题根源是/login路径(包括带参数的请求)未被放行,在SecurityConfig的authorizeHttpRequests中添加requestMatchers("/login").permitAll()即可解决:
修改后的filterChain方法:
@Bean public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { return httpSecurity .authorizeHttpRequests(authorize -> authorize .requestMatchers("/register", "/login").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form .permitAll() .loginPage("/login") .defaultSuccessUrl("/") .failureHandler(authenticationFailureHandler) ) .logout(logout -> logout .logoutUrl("/logout") ) .authenticationProvider(authenticationProvider) .build(); }
疑惑解答
为什么未添加/login的permitAll()时,未认证状态下登录页面还能正常显示?
这是因为Spring Security的formLogin配置中,当你指定.loginPage("/login")时,框架会自动放行/login的GET请求(用于显示登录页面),但不会自动放行/login的POST请求(认证提交)以及带参数的/login?xxx请求。你的全局规则是anyRequest().authenticated(),所以当首次认证失败后,AuthenticationFailureHandler跳转到login?badCredentials这个GET请求时,会被Security拦截,因为该请求需要认证,进而被重定向回/login,导致你看起来停留在了登录页面,没有跳转到预期的带参数URL。
添加requestMatchers("/login").permitAll()后,所有/login路径的请求(包括带参数的)都被放行,失败跳转的URL就能正常访问,显示对应的错误标识。
内容的提问来源于stack exchange,提问作者Nerfi

