springspring-bootuserdetailsservice

How to override default userDetailsService and passwordEncoder for spring-boot project


I have this `application.properties' file:

security.basic.enabled=false

spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://localhost:5432/appdata
spring.datasource.username=kleber
spring.datasource.password=123456
spring.datasource.continue-on-error=true

sprinf.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.dialect=org.hibernate.dialect.PostgreSQLDialect
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.generate-ddl=true

spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML5
spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.content-type=text/html
spring.thymeleaf.cache=false

spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
spring.servlet.multipart.file-size-threshold=10MB

server.tomcat.max-http-post-size=10MB

and this App class:

@SpringBootApplication
@Controller
public class AppApplication extends SpringBootServletInitializer {

    public static void main(String[] args) {
        SpringApplication.run(AppApplication.class, args);
    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(AppApplication.class);
    }

    @Bean
    public WebSecurityCustomizer ignoringCustomizer() {
        return (web) -> web
                .ignoring()
                        .antMatchers("/", "/login", "/logout", "/register", "/error", "/css/**", "/js/**", "/img/**");
    }
    
    @Bean
    public SpringSecurityDialect springSecurityDialect() {
        return new SpringSecurityDialect();
    }

    @RequestMapping(value = "/")
    public String index(Model model) {
        return "index";
    }

    @RequestMapping(value = "/login")
    public String login(Model model) {
        return "login";
    }

    @RequestMapping(value = "/register", method=RequestMethod.GET)
    public String register(Model model) {
        model.addAttribute("obj", new Usuario());
        return "register";
    }

    @RequestMapping(value = "/register", method=RequestMethod.POST)
    public String register(@ModelAttribute Usuario usuario) {
        return "redirect:/login";
    }
}

I have tried add a Bean to the class above, like that:

@Bean
public UserDetailsService userDetailsService() {
    return new UserDetailsService() { ... }
}

@Bean PasswordEncoder passwordEncoder() {
    return new PasswordEncoder() { ... }
}

but this do not work. My guess is I need some way to configure them in the method WebSecurityCustomizer ignoringCustomizer() , but looking the documentation for the class WebSecurityCustomizer I do not see any way to do that.

Anyone can give any hints of how to do that?

UPDATE #1

Searching through the official site, I found some reference documentation and blog post telling the recommended way to do some actions close to what I need, but I am still struggling to get right.

First link, it's the reference page for the deprecated class WebSecurityConfigurerAdapter, where it's said to:

Use a SecurityFilterChain Bean to configure HttpSecurity or a WebSecurityCustomizer Bean to configure WebSecurity

HttpSecurity have a method to define a UserDetailsService Bean, but how I use it in my code?

The other link it's a blog post describing the old way to do some authentication tasks, and the new recommended way. The closest examples to what I want it's in the section about JDBC Authentication and In-Memory Authentication, and both of them are based on the use of a UseDetailsManager, if I am not wrong. I also tried add a new Bean like that:

@Bean
public UserDetailsManager userDetailsManager() {
    return new UserDetailsManager() { ... }
}

but does not work. What's the right way to do override the beans I want now?

UPDATE 2

I currently have this code, which is still not working properly. with this configuration, I can register a new user (which is created in the database with success), but I cannot login with this user.

@SpringBootApplication
@Controller
public class App extends SpringBootServletInitializer {
    @Autowired
    UsuarioDao usuarioDao;

    @Autowired
    CredencialDao credencialDao;

    public static void main(String[] args) {
        SpringApplication.run(App.class, args);
    }

    @Override
    protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(App.class);
    }

    @Bean
    public SpringSecurityDialect springSecurityDialect() {
        return new SpringSecurityDialect();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .cors().disable()
            .authorizeRequests()
                .antMatchers("/", "/login", "/logout", "/register", "/error", "/css/**", "/js/**", "/img/**").permitAll()
                .anyRequest().authenticated()
            .and()
            .authenticationProvider(authProvider());
        return http.build();
    }

    @Bean
    public DaoAuthenticationProvider authProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        provider.setUserDetailsService(userDetailsService());
        return provider;
    }

    @Bean
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) {
                System.out.println("loadUserByUsername: " + username);
                return usuarioDao.findBy("username", username).get(0);
            }
        };
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                try {
                    MessageDigest md = MessageDigest.getInstance("MD5");
                    md.update(rawPassword.toString().getBytes());
                    byte[] digest = md.digest();

                    StringBuilder sb = new StringBuilder();
                    for(int i=0; i<digest.length; i++) sb.append(Integer.toString((digest[i] & 0xff) + 0x100, 16).substring(1));
                    return sb.toString();
                } catch (Exception e) {
                    return null;
                }
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return encodedPassword.equals(encode(rawPassword));
            }
        };
    }

    @RequestMapping(value = "/")
    public String index(Model model) {
        return "index";
    }

    @RequestMapping(value = "/login")
    public String login(Model model) {
        return "login";
    }

    @RequestMapping(value = "/register", method=RequestMethod.GET)
    public String register(Model model) {
        model.addAttribute("obj", new Usuario());
        return "register";
    }

    @RequestMapping(value = "/register", method=RequestMethod.POST)
    public String register(@ModelAttribute Usuario usuario) {
        try {
            usuario.setPassword(passwordEncoder().encode(usuario.getPassword()));
            usuario.setCredenciais(new ArrayList<Credencial>());
            usuario.getCredenciais().add(credencialDao.findBy("nome", "USER").get(0));
            usuarioDao.insert(usuario);
            return "login";
        } catch (Exception e) {
            e.printStackTrace();
            return "register";
        }
    }
}

Solution

  • I think there could be several issues with your actual code.

    First, due to the fact you are using form login, please, try providing the appropriate configuration when defining your filter chain, for example:

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .cors().disable()
            .authorizeHttpRequests()
                .antMatchers("/", "/login", "/logout", "/register", "/error", "/css/**", "/js/**", "/img/**").permitAll()
                .anyRequest().authenticated()
                .and()
                    .formLogin()
                        .loginPage("/login")
                         // Note the inclusion of the login processing url value
                        .loginProcessingUrl("/authenticate")
                         // One that you consider appropriate
                        .defaultSuccessUrl("/home")
                        .failureUrl("/login?error=true")
                .and().authenticationProvider(authProvider());
        return http.build();
    }
    

    As you can see, we are indicating that the login page will be handled by your controller /login mapping:

    @RequestMapping(value = "/login")
    public String login(Model model) {
        return "login";
    }
    

    In addition, we indicated /authenticate as the login processing url configuration: this will activate all the authentication stuff provided by you and Spring Security to authenticate your users.

    Note that you need to change your login.html page as well, because in your current implementation the username/password form is being submitted as well against /login - this is probably the cause of the problem you described in your update #2. Following the example, the form should be submitted against /authenticate:

    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Login</title>
        <linl rel="stylesheet" th:href="@{/css/style.css}"></linl>
    </head>
    <body>
        <form method="post" th:action="@{/authenticate}">
            <input type="text" name="username" placeholder="Usuário">
            <input type="password" name="password" placeholder="Senha">
            <input type="submit" value="Entrar">
        </form>
        
        <script th:src="@{/js/script.js}"></script>
    </body>
    </html>
    

    Note that we included as well a route, /home to request your app after a successful authentication. Yu can define in App something like:

    @RequestMapping(value = "/home")
    public String home(Model model) {
        return "home";
    }
    

    And a simple HTML test page, home.html:

    <!DOCTYPE html>
    <html lang="pt-BR">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
        <link rel="stylesheet" th:href="@{/css/style.css}">
    </head>
    <body>
        Authenticated!!
    
        <script th:src="@{/js/script.js}"></script>
    </body>
    </html>
    

    In order to make this work you should change an additional piece in your software. According to the way you defined your code, when Spring Security tries reading your Usuario GrantedAuthorities Hibernate will issue the well known failed to lazily initialize a collection of role... because in your current implementation you are reading the credentials stored in your database but there is no session:

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<Autorizacao> lista = new ArrayList<Autorizacao>();
        for(Credencial credencial : credenciais) {
            lista.addAll(credencial.getAutorizacoes());
        }
        return lista;
    }
    

    You can probably solve the issue in different ways, especially consider using @Transactional, but one straight forward solution could be the folllowing.

    First, modify your Usuario object and include a transient property for storing the Spring Security granted credentials:

    package org.kleber.app.model.usuario;
    
    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.Collections;
    import java.util.List;
    import javax.persistence.Column;
    import javax.persistence.Entity;
    import javax.persistence.GeneratedValue;
    import javax.persistence.GenerationType;
    import javax.persistence.Id;
    import javax.persistence.ManyToMany;
    import javax.persistence.Transient;
    
    import org.kleber.app.model.credencial.Credencial;
    import org.kleber.app.model.Model;
    import org.kleber.app.model.autorizacao.Autorizacao;
    import org.springframework.security.core.GrantedAuthority;
    import org.springframework.security.core.userdetails.UserDetails;
    
    @Entity
    public class Usuario extends Model implements UserDetails {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        Integer id;
    
        @Column
        String username;
    
        @Column
        String password;
    
        @Column
        String firstName;
    
        @Column
        String lastName;
    
        @Column
        String email;
    
        @ManyToMany
        List<Credencial> credenciais;
    
        @Transient
        Collection<? extends GrantedAuthority> authorities = Collections.emptySet();
    
        public Integer getId() {
            return id;
        }
    
        public void setId(Integer id) {
            this.id = id;
        }
    
        public String getUsername() {
            return username;
        }
    
        public void setUsername(String username) {
            this.username = username;
        }
    
        public String getPassword() {
            return password;
        }
    
        public void setPassword(String password) {
            this.password = password;
        }
    
        public String getFirstName() {
            return firstName;
        }
    
        public void setFirstName(String firstName) {
            this.firstName = firstName;
        }
    
        public String getLastName() {
            return lastName;
        }
    
        public void setLastName(String lastName) {
            this.lastName = lastName;
        }
    
        public String getEmail() {
            return email;
        }
    
        public void setEmail(String email) {
            this.email = email;
        }
    
        public List<Credencial> getCredenciais() {
            return credenciais;
        }
    
        public void setCredenciais(List<Credencial> credenciais) {
            this.credenciais = credenciais;
        }
    
        public boolean isEnabled() {
            return true;
        }
    
        public boolean isCredentialsNonExpired() {
            return true;
        }
    
        public boolean isAccountNonExpired() {
            return true;
        }
    
        public boolean isAccountNonLocked() {
            return true;
        }
    
        public void setAuthorities(Collection<? extends GrantedAuthority> authorities) {
            this.authorities = authorities;
        }
    
        @Override
        public Collection<? extends GrantedAuthority> getAuthorities() {
            return authorities;
        }
    }
    

    Next, include a custom method in UsuarioDao in order to obtain the credentials when you have an active Session:

    package org.kleber.app.model.usuario;
    
    import java.util.ArrayList;
    import java.util.List;
    
    import javax.persistence.EntityManager;
    
    import org.kleber.app.model.Dao;
    import org.kleber.app.model.autorizacao.Autorizacao;
    import org.kleber.app.model.credencial.Credencial;
    import org.springframework.stereotype.Repository;
    import org.springframework.transaction.annotation.Transactional;
    
    @Repository
    public class UsuarioDao extends Dao<Usuario> {
        UsuarioDao() {
            super(Usuario.class);
        }
    
        public Usuario findByUsername(String username) {
            EntityManager entityManager = getEntityManager();
            entityManager.getTransaction().begin();
            Usuario usuario = (Usuario) entityManager.createQuery("SELECT a FROM Usuario a WHERE a.username = :value").setParameter("value", username).getSingleResult();
            // Retrieve the credentials here
            // On the contrary, you will face: failed to lazily initialize a collection of role...
            // Please consider using @Transactional instead
            List<Credencial> credenciais = usuario.getCredenciais();
            List<Autorizacao> autorizacaos = new ArrayList<Autorizacao>();
            for(Credencial credencial : credenciais) {
                autorizacaos.addAll(credencial.getAutorizacoes());
            }
            usuario.setAuthorities(autorizacaos);
            entityManager.getTransaction().commit();
            entityManager.close();
            return usuario;
        }
    }
    

    Please, as I mentioned, consider use @Transactional and Spring built-in transaction demarcation mechanisms instead of handling your transactions on your own in this way.

    Finally, user this new method in your UserDetailsService implementation:

    @Bean
    public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
            @Override
            public UserDetails loadUserByUsername(String username) {
                System.out.println("loadUserByUsername: " + username);
                return usuarioDao.findByUsername(username);
            }
        };
    }
    

    Instead of creating this new method in UsuarioDao another, perhaps better, possibility would be creating a UserService @Service that wrap this Usuario and Credentials initialization process: this service would be then the one used by your UserDetailsService implementation.

    For completeness, this is how the App class would end looking like:

    package org.kleber.app;
    
    import org.springframework.boot.autoconfigure.SpringBootApplication;
    import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
    import org.springframework.boot.SpringApplication;
    import org.springframework.boot.builder.SpringApplicationBuilder;
    
    import org.springframework.context.annotation.Bean;
    import org.thymeleaf.extras.springsecurity5.dialect.SpringSecurityDialect;
    
    import org.springframework.security.web.SecurityFilterChain;
    import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
    import org.springframework.security.config.annotation.web.builders.HttpSecurity;
    import org.springframework.security.core.userdetails.UserDetailsService;
    import org.springframework.security.core.userdetails.UserDetails;
    import org.springframework.security.crypto.password.PasswordEncoder;
    
    import java.security.MessageDigest;
    
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RequestMethod;
    import org.springframework.web.bind.annotation.ModelAttribute;
    import org.springframework.ui.Model;
    
    import java.util.ArrayList;
    
    import org.springframework.beans.factory.annotation.Autowired;
    import org.kleber.app.model.usuario.UsuarioDao;
    import org.kleber.app.model.credencial.CredencialDao;
    import org.kleber.app.model.usuario.Usuario;
    import org.kleber.app.model.credencial.Credencial;
    
    @SpringBootApplication
    @Controller
    public class App extends SpringBootServletInitializer {
      @Autowired
      UsuarioDao usuarioDao;
    
      @Autowired
      CredencialDao credencialDao;
    
      public static void main(String[] args) {
        SpringApplication.run(App.class, args);
      }
    
      @Override
      protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
        return application.sources(App.class);
      }
    
      @Bean
      public SpringSecurityDialect springSecurityDialect() {
        return new SpringSecurityDialect();
      }
    
      @Bean
      public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .cors().disable()
            .authorizeHttpRequests()
            .antMatchers("/", "/login", "/logout", "/register", "/error", "/css/**", "/js/**", "/img/**").permitAll()
            .anyRequest().authenticated()
            .and()
            .formLogin()
            .loginPage("/login")
            .loginProcessingUrl("/authenticate")
            .defaultSuccessUrl("/home")
            .failureUrl("/login?error=true")
            .and().authenticationProvider(authProvider());
        return http.build();
      }
    
      @Bean
      public DaoAuthenticationProvider authProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        provider.setUserDetailsService(userDetailsService());
        return provider;
      }
    
      @Bean
      public UserDetailsService userDetailsService() {
        return new UserDetailsService() {
          @Override
          public UserDetails loadUserByUsername(String username) {
            System.out.println("loadUserByUsername: " + username);
            return usuarioDao.findByUsername(username);
          }
        };
      }
    
      @Bean
      public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
          @Override
          public String encode(CharSequence rawPassword) {
            try {
              MessageDigest md = MessageDigest.getInstance("MD5");
              md.update(rawPassword.toString().getBytes());
              byte[] digest = md.digest();
    
              StringBuilder sb = new StringBuilder();
              for (int i = 0; i < digest.length; i++)
                sb.append(Integer.toString((digest[i] & 0xff) + 0x100, 16).substring(1));
              return sb.toString();
            } catch (Exception e) {
              return null;
            }
          }
    
          @Override
          public boolean matches(CharSequence rawPassword, String encodedPassword) {
            return encodedPassword.equals(encode(rawPassword));
          }
        };
      }
    
      @RequestMapping(value = "/")
      public String index(Model model) {
        return "index";
      }
    
      @RequestMapping(value = "/home")
      public String home(Model model) {
        return "home";
      }
    
      @RequestMapping(value = "/login")
      public String login(Model model) {
        return "login";
      }
    
      @RequestMapping(value = "/register", method = RequestMethod.GET)
      public String register(Model model) {
        model.addAttribute("obj", new Usuario());
        return "register";
      }
    
      @RequestMapping(value = "/register", method = RequestMethod.POST)
      public String register(@ModelAttribute Usuario usuario) {
        try {
          usuario.setPassword(passwordEncoder().encode(usuario.getPassword()));
          usuario.setCredenciais(new ArrayList<Credencial>());
          usuario.getCredenciais().add(credencialDao.findBy("nome", "USER").get(0));
          usuarioDao.insert(usuario);
          return "login";
        } catch (Exception e) {
          e.printStackTrace();
          return "register";
        }
      }
    }
    

    Probably it could be improved in different ways, but the suggested setup should allow you to successfully access your app:

    welcome page