javaspringspring-bootspring-security

Still receiving 403 error after Spring Security login


I am learning spring and and I am learning traditional user / password authentication with Spring Security.

Currently, I'm using my own login page. In my controller, I am able to verify the users credentials using my userService. LoginController snippet:

/**
     * Displays the login page.
     * <p>
     * This method is invoked when a user requests the login page. It initializes
     * the login form and adds it to the model.
     * </p>
     *
     * @param model the model to be used in the view.
     * @return the name of the login view (Thymeleaf template).
     */
    @GetMapping("/login")
    public String loginGet(Model model) {
        log.info("loginGet: Get login page");
        model.addAttribute("loginForm", new LoginForm());
        return "login";
    }

    /**
     * Processes the login form submission.
     * <p>
     * This method handles POST requests when a user submits the login form. It checks
     * the validity of the submitted form and validates the user's credentials.
     * On success, it redirects to the search page; on failure, it reloads the login page with an error.
     * </p>
     *
     * @param loginForm the login form submitted by the user.
     * @param result    the result of the form validation.
     * @param attrs     attributes to be passed to the redirect.
     * @param httpSession the HTTP session for storing the authenticated user.
     * @param model     the model to add error messages, if necessary.
     * @return the name of the view to render.
     */
    @PostMapping("/login")
    public String loginPost(@Valid @ModelAttribute LoginForm loginForm, BindingResult result,
                            RedirectAttributes attrs, HttpSession httpSession, Model model) {
        log.info("loginPost: User '{}' attempted login", loginForm.getUsername());

        // Check for validation errors in the form submission
        if (result.hasErrors()) {
            log.info("loginPost: Validation errors: {}", result.getAllErrors());
            return "login";
        }

        // Validate the username and password
        if (!loginService.validateUser(loginForm.getUsername(), loginForm.getPassword())) {
            log.info("loginPost: Username and password don't match for user '{}'", loginForm.getUsername());
            model.addAttribute("errorMessage", "That username and password don't match.");
            return "login";  // Reload the form with an error message
        }

        // If validation is successful, retrieve the user and set the session
        User foundUser = userService.getUser(loginForm.getUsername());
        attrs.addAttribute("username", foundUser.getUsername());
        httpSession.setAttribute("currentUser", foundUser);

        log.info("loginPost: User '{}' logged in", foundUser.getUsername());

        return "redirect:/search";  // Redirect to the search page after successful login
    }

However, when the user is redirected to the search page, a 403 error is produced, because the user doesn't have access to the search page. I thought I had my securityConfig class set up correctly, where a user not logged in can access the login and register pages, but all other pages can only be viewed by logged in users.

SecurityConfig:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomUserDetailsService userDetailsService;

    public SecurityConfig(final CustomUserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }

    /**
     * Bean for password encoding using BCrypt.
     *
     * @return a BCryptPasswordEncoder instance for encoding passwords.
     */
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    /**
     * Configures the security filter chain for the application.
     *
     * @param http the HttpSecurity object to configure.
     * @return the configured SecurityFilterChain.
     * @throws Exception if an error occurs while configuring the security settings.
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth
                .requestMatchers(
                        "/",
                        "/login",
                        "/register",
                        "/js/**",
                        "/css/**",
                        "/images/**").permitAll()
                .anyRequest().authenticated());
        http.logout(lOut -> {
            lOut.invalidateHttpSession(true)
                    .clearAuthentication(true)
                    .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                    .logoutSuccessUrl("/login?logout")
                    .permitAll();
        });
        http.csrf().disable();

        return http.build();
    }

    @Bean
    public AuthenticationManager authManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService);
        authenticationManagerBuilder.authenticationProvider(authenticationProvider);
        return authenticationManagerBuilder.build();
    }
}

CustomUserDetailsSerivce:

@Service
public class CustomUserDetailsService implements UserDetailsService {
    private static final Logger log = LoggerFactory.getLogger(CustomUserDetailsService.class);


    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        log.info("loadUserByUsername: username={}", username);
        final List<User> user = userRepository.findByUsernameIgnoreCase(username);
        if(user.size() != 1) {
            throw new UsernameNotFoundException("User not found");
        }
        return new CustomUserDetails(user.getFirst());
    }
}

CustomUserDetails:

public record CustomUserDetails(User user) implements UserDetails {
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return List.of();
    }

    @Override
    public String getPassword() {
        return user.getHashedPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

I've tried several different approaches, but everything has resulted in the 403 error. Do I need a token? How can I allow the user to access all of my other pages once logged in?

logging messages after user enters valid credentials and attempts to log in:

[DEBUG] - from org.springframework.security.web.FilterChainProxy in http-nio-8080-exec-4
Securing POST /login

[DEBUG] - from org.springframework.security.web.FilterChainProxy in http-nio-8080-exec-4
Secured POST /login

[INFO] - from edu.school.appName.web.controller.LoginController in http-nio-8080-exec-4
loginPost: User 'testUser' attempted login

[INFO] - from edu.school.appName.service.LoginServiceImpl in http-nio-8080-exec-4
validateUser: Attempting to validate user 'testUser' for login

[INFO] -
from edu.school.appName.service.LoginServiceImpl in http-nio-8080-exec-4
validateUser: User 'testUser' found

[INFO] - from edu.school.appName.service.LoginServiceImpl in http-nio-8080-exec-4
validateUser: Successful login for 'testUser'

[INFO] - from edu.school.appName.web.controller.LoginController in http-nio-8080-exec-4
loginPost: User 'testUser' logged in

[DEBUG] - from org.springframework.security.web.authentication.AnonymousAuthenticationFilter in http-nio-8080-exec-4
Set SecurityContextHolder to anonymous SecurityContext

[DEBUG ]- from org.springframework.security.web.FilterChainProxy in http-nio-8080-exec-5
Securing GET /search?username=testUser

[DEBUG] - from org.springframework.security.web.authentication.AnonymousAuthenticationFilter in http-nio-8080-ехес-5
Set SecurityContextHolder to anonymous SecurityContext

[DEBUG] - from org.springframework.security.web.savedrequest.HttpSessionRequestCache in http-nio-8080-exec-5
Saved request http://localhost:8080/search?username=testUser& continue to session

[DEBUG] - from org.springframework.security.web.authentication.Http403ForbiddenEntryPoint in http-nio-8080-eхес-5
Pre-authenticated entry point called. Rejecting access

validateUser function for context in logs:

/**
     * Validates the provided username and password by checking the user data stored in the repository.
     *
     * @param username the username provided by the user during login
     * @param password the password provided by the user during login
     * @return true if the user exists and the password matches; false otherwise
     */
    @Override
    public boolean validateUser(String username, String password) {
        log.info("validateUser: Attempting to validate user '{}' for login", username);

        // Perform a case-insensitive search for the user in the repository.
        List<User> users = userRepo.findByUsernameIgnoreCase(username);

        // Check if exactly one user is found. If zero or more than one are found, return false.
        if (users.size() != 1) {
            log.info("validateUser: Found {} users with username '{}'", users.size(), username);
            return false;
        }

        User u = users.get(0);
        log.info("validateUser: User '{}' found", u.getUsername());

        // Check if the provided password matches the hashed password stored in the database.
        if (!passwordEncoder.matches(password, u.getHashedPassword())) {
            log.info("validateUser: Password does not match for user '{}'", username);
            return false;
        }

        // User exists, and the password matches the stored hash.
        log.info("validateUser: Successful login for user '{}'", username);
        return true;
    }

I also tried using AuthenticationManager to handle the login logic:

@Bean
    public AuthenticationManager authManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder =
                http.getSharedObject(AuthenticationManagerBuilder.class);
        
        DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
        authenticationProvider.setUserDetailsService(userDetailsService);
        authenticationProvider.setPasswordEncoder(passwordEncoder());
        authenticationManagerBuilder.authenticationProvider(authenticationProvider);
        
        return authenticationManagerBuilder.build();
    }

Solution

  • The problem is that you have a lack of understanding how spring security works and what stipulates a user as logged in. Spring security states the following in the Spring Security Documentation.

    The SecurityContextHolder is where Spring Security stores the details of who is authenticated. Spring Security does not care how the SecurityContextHolder is populated. If it contains a value, it is used as the currently authenticated user.

    So in one way or another, the SecurityContextHolder needs to be populated.

    In standard spring security, all you need to do, is the following:

    The simplest way of doing this is:

    And thats it.

    Spring security will set up a /login endpoint for you that accepts username and password as FORM parameters (not json). It will automatically set up a DaoAuthenticationProvider that will handle all the password checking for you and automatically call the findByUsername function on the UserDetailsService. And it will automatically populate the SecurityContextHolder for you.

    But you decided to opt out of all this that spring security automatically sets up for you, to write your own security solution. Why you decided to do that i have no idea.

    But the solution is the following.

    i dont understand why you have decided to write all this strange custom code, and just not read the docs and follow them?