返回

Spring Security loadUserByUsername 未触发?常见问题及排查指南

java

Spring Security 中 loadUserByUsername 未触发的故障排除

在使用 Spring Security 进行身份验证时,loadUserByUsername 方法未被调用是一种常见的问题,导致用户无法登录。 尽管配置了 UserDetailsService 实现,且在程序启动期间已经初始化了配置。 此问题通常意味着配置中存在一些微妙之处,阻止 Spring Security 正确地获取用户信息,进而影响后续的用户认证流程。

问题分析

出现此问题的几种常见原因是:

  1. 请求未经过 Spring Security 过滤器链 :如果用户提交的登录请求没有被 Spring Security 的过滤器拦截,loadUserByUsername 就不会被调用。 这通常是由于配置中的 securityFilterChain 未正确配置请求匹配器,从而允许请求绕过身份验证。
  2. 表单登录配置不匹配 :Spring Security 需要与登录表单的名称相匹配。如果在登录表单的输入字段名称或提交的目标地址配置不正确,身份验证将失败,loadUserByUsername 方法也不会被调用。
  3. 配置的 AuthenticationProvider 不正确 : 在配置中使用了 DaoAuthenticationProvider, 并且配置的密码编辑器或用户详情服务不正确的话,验证就不会成功。
  4. 重定向导致验证过程失败 :如果在验证过程中出现循环重定向或其他错误,那么验证不会正确执行,loadUserByUsername 不会被调用。
  5. 未正确的理解Spring Security中的authentication flow : 在 Spring security中,验证通常不是发生在controller方法里面的,UserDetailsService 用于将数据库中获取的用户详情和form提交的数据,构建Authentication对象,交由AuthenticationManager处理,返回AuthenticatedAuthentication,最终Spring Security会根据这个对象的信息判断是否已经登录。

解决方案及实施步骤

针对以上问题,可以按照以下步骤进行排查和解决:

方案一: 验证请求匹配器

  • 问题securityFilterChain 中的配置可能有误,导致登录请求未被拦截。

  • 解决思路 : 确保 formLogin().loginPage("/login") 指示的 URL 与实际的登录页面的路径一致。此外,确保没有配置了任何通配符使得/login 的路径被错误地拦截掉。查看打印的日志,是否存在请求经过了 org.springframework.security.web.FilterChainProxy,如果没有任何security 拦截的log打印,那么就需要检查request匹配的设置是否生效。

  • 操作步骤

    1. 仔细检查securityFilterChainrequestMatchers 针对 /login/home 的配置,确保访问路径与配置文件和前端页面中的对应。确保其他的路径匹配配置是否正确,会不会和 /login/home 冲突导致认证失败。
    2. 确保你的静态资源(CSS、JavaScript)的 requestMatchers 使用 permitAll 允许未经验证的访问,不会拦截前端静态资源请求。
  • 示例代码

   @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        System.out.println("inside securityFilterChain");
        http
            .authorizeHttpRequests()
            .requestMatchers("/css/**", "/js/** ", "/images/**", "/static/** ").permitAll()
            .requestMatchers("/login", "/register", "*.css").permitAll() // Public access to login and register
            .requestMatchers("/add-product", "/modify-product", "/remove-product").hasRole("ADMIN") // Restrict product modification to ADMIN roles
            .requestMatchers("/view-product").authenticated() // Requires users to be authenticated
             .requestMatchers("/home").authenticated()  //需要认证后才能访问 home page
            .anyRequest().authenticated() // All other requests need to be authenticated
            .and()
            .formLogin()
                .loginPage("/login") // 指向登陆页面的路径,如果login页面没有认证,无法成功跳转到home页面
                 .loginProcessingUrl("/home") // 指定用于身份验证的处理地址。这个路径与form中的 action=/home相一致。如果这里不一致,也会导致验证失败。
                .defaultSuccessUrl("/home", true) // Redirect to home after login
                .failureUrl("/login?error") // 增加 failure 参数到登陆界面,在前端显示验证失败提示信息。
            .and()
            .logout()
                .logoutSuccessUrl("/login") // Redirect after logout
                .permitAll()
            .and()
            .sessionManagement() // Configure session management
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) // Default policy
                .maximumSessions(1) // Limit the number of concurrent sessions per user
                .expiredUrl("/login?expired") // Redirect to login page if session expires
            .and()
            .and()
            .csrf().disable(); // Disable CSRF for non-browser clients or API access

        return http.build();
    }

方案二:确保 表单元素和配置中的一致性

  • 问题 :表单中定义的 usernamepassword 字段名 与 Spring Security 的预期不匹配。
  • 解决思路 :Spring Security 默认期望表单中的用户名参数名为 username,密码参数名为 password。 检查前端的登录页面以及 securityFilterChain 配置确保其配置是一致的。 另外 loginProcessingUrl 指的是 spring Security拦截form表单的路径。而不是前端页面展示路径。
  • 操作步骤
    1. 检查登录页面的 HTML 表单,确保 <input> 元素的 name 属性分别为 usernamepassword
    2. 查看securityFilterChain配置。
    3. 注意 <form action="/home" method="POST"> , form表单提交的地址,一定要与 loginProcessingUrl 定义的路径一致。
  • 示例代码 (HTML 表单片段)
<form action="/home" method="POST">
  <input type="text" name="username" placeholder="Enter your username" required>
  <input type="password" name="password" placeholder="Enter your password" required>
  <input type="submit" class="button" value="Login">
</form>

方案三:配置 DaoAuthenticationProvider 正确的userDetailsService and PasswordEncoder

  • 问题 : 可能 authenticationProvider中注入的用户信息获取器和密码比较器不正确。
  • 解决思路 : 需要仔细检查 配置中的 authenticationProvider, 其中使用的用户服务(customUserDetailsService)和密码编辑器 (passwordEncoder) 是否正常。 在你的代码中authenticationProviderpasswordEncoder 配置正常,但是如果你手动实现一个,就需要仔细检查。
  • 操作步骤
    1. 确认 authenticationProvider 中配置的 userDetailsService 的实例。检查是否有错误的实例,如重复配置导致实例没有按照预期执行。
    2. 检查 passwordEncoder, 使用默认 BCryptPasswordEncoder, 在实际代码中是需要检查你实现代码的密码编解码逻辑。
    3. 可以增加日志打印验证,确认当前实例是自定义的 customUserDetailsService
     @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        System.out.println("inside authentication provider");
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(customUserDetailsService); // Use injected CustomUserDetailsService
        authProvider.setPasswordEncoder(passwordEncoder()); // Set password encoder
         System.out.println("AuthenticationProvider setup with UserDetailsService:" + customUserDetailsService.getClass().getName());
        return authProvider;
    }

方案四:Spring Security 身份认证机制分析

  • 问题: LoginController中通过passwordEncoder.matches 判断用户名密码是否正确是不正确的用法。 Spring Security有自己内置的身份认证流程。 如果使用不当就会导致出现重复认证。且自定义验证并没有用到配置的userDetailService。

  • 解决思路: Spring Security的认证机制由AuthenticationManager触发, AuthenticationManager 又是由我们配置的 AuthenticationProviderUserDetailService提供实现支持, 所以不应该跳过这些环节。 而是直接调用内置的登录入口,验证失败就会被重定向到 failureUrl, 验证成功跳转到defaultSuccessUrl 配置。 controller的主要功能是根据 securityContext 获取用户信息,并对获取的当前用户,根据角色等进行其他处理,而不是认证用户。

  • 操作步骤:

    1. 删除 controller中@PostMapping("/home")方法以及对应的代码逻辑。 Spring Security 会根据/home 表单提交处理验证。
  • 代码修改:

删除 controller中以下代码

 @PostMapping("/home")
    public String login(@RequestParam String username, @RequestParam String password) {
        // Retrieve user from the database
        System.out.println("login endpoint");
        User user = userService.findByUsername(username);
      
        Iterator<Role> iterator = user.getRoles().iterator();
        while (iterator.hasNext()) {
            Role fruit = iterator.next();
            System.out.println(fruit.getRoleName());
        }
        
        if (user != null && passwordEncoder.matches(password, user.getPassword())) {
            System.out.println("authentication complete");
            return "home";  // Password matches, redirect to home
        } else {
            System.out.println("authentication incomplete");
            return "index";  // Invalid login, return to login page
        }
    }

在你的配置文件中增加配置/login?error 在页面显示提示信息

    .formLogin()
            .loginPage("/login")
           .loginProcessingUrl("/home")
            .defaultSuccessUrl("/home", true) 
            .failureUrl("/login?error") 

其他建议

  • loadUserByUsername 方法中使用日志记录来验证方法是否被调用。可以使用debug level打印用户名, 方便排查问题
    java @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { log.debug("Attempting to load user: {}", username); System.out.println("inside loaduserbyusername custom user");

通过以上排查,通常可以解决 Spring Boot 项目中 loadUserByUsername 未触发的问题。 注意在实现 UserDetailsService 时,UserDetails 需要完整的数据信息。并需要合理利用 debug 模式。如有任何问题,应检查完整的错误信息和日志。