Search code examples
javaspringspring-bootjwt

Why does Spring Boot return 403 Forbidden for a POST request to a secured endpoint even with a valid JWT?


I'm working on a Spring Boot project where I'm implementing JWT-based authentication. I have a /api/game/save endpoint that should accept a POST request with a valid JWT. Despite including the token in the Authorization header, I keep getting a 403 Forbidden response:

Security Configuration:

package com.clicker.configuration;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
                        .requestMatchers("/api/game/**").authenticated()
                        .anyRequest().authenticated());

        http.sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }
}

JWT Utility (JwtUtil):

package com.clicker.component;

import io.jsonwebtoken.JwtBuilder;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Component;

import java.util.Date;

import static io.jsonwebtoken.Jwts.builder;

@Component
public class JwtUtil {

    private static final String SECRET_KEY = "secret_key"; // Not my real secret key ofc
    private static final long EXPIRATION_TIME = 1000 * 60 * 60; // 10 hours


    public String generateToken(String username) {
        return builder()
                .setSubject(username)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
                .compact();
    }

    public boolean validateToken(String token) {
        try {
            Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token);
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            return false;
        }
    }

    public String extractUsername(String token) {
        return Jwts.parser()
                .setSigningKey(SECRET_KEY)
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
    }
}

Controller Endpoint:

package com.clicker.controller;

import com.clicker.service.GameDataService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/game")
public class GameDataController {

    @Autowired
    private GameDataService gameDataService;

    @PostMapping("/save")
    public ResponseEntity<?> saveGameData(@RequestHeader("Authorization") String token,
                                          @RequestBody String gameDataJson) {
        return gameDataService.saveGameData(token, gameDataJson);
    }

    @PostMapping("/load")
    public ResponseEntity<?> loadGameData(@RequestHeader("Authorization") String token) {
        return gameDataService.loadGameData(token);
    }
}

Service:

package com.clicker.service;

import com.clicker.component.JwtUtil;
import com.clicker.entity.GameData;
import com.clicker.entity.User;
import com.clicker.repository.GameDataRepository;
import com.clicker.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;

@Service
public class GameDataService {

    @Autowired
    private GameDataRepository gameDataRepository;

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private JwtUtil jwtUtil;

    public ResponseEntity<?> saveGameData(String token, String gameDataJson) {

        String username = jwtUtil.extractUsername(token.substring(7));
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new RuntimeException("User not found!"));

        GameData gameData = gameDataRepository.findByUser(user)
                .orElse(new GameData());

        gameData.setUser(user);
        gameData.setData(gameDataJson);
        gameDataRepository.save(gameData);

        return ResponseEntity.ok("Game data saved successfully!");
    }

    public ResponseEntity<?> loadGameData(String token) {
        String username = jwtUtil.extractUsername(token.substring(7));
        User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new RuntimeException("User not found!"));

        return gameDataRepository.findByUser(user)
                .map(gameData -> ResponseEntity.ok(gameData.getData()))
                .orElse(ResponseEntity.ok("{\n" +
                        "    \"playerStats\": { \"solarEnergy\": 0, \"multiplyCostData\": 200, \"solarEnergyPerClick\": 1, \"solarEnergyPerSecond\": 0},\n" +
                        "    \"planets\": {\n" +
                        "        \"mercury\": { \"unlocked\": false, \"cost\": 500 },\n" +
                        "        \"venus\": { \"unlocked\": false, \"cost\": 2500 },\n" +
                        "        \"earth\": { \"unlocked\": false, \"cost\": 12500 },\n" +
                        "        \"moon\": { \"unlocked\": false, \"cost\": 62500 }\n" +
                        "    }\n" +
                        "}"));
    }

    public boolean validateToken(String token) {
        return jwtUtil.validateToken(token);
    }
}

application.properties:

spring.application.name=Solar-Planet-Clicker
spring.datasource.url=jdbc:mysql://localhost:3306/solar_planet_clicker
spring.datasource.username=root
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE

When I send a POST request to /api/game/save with a valid token and body, I get a 403 Forbidden response.


Solution

  • Assuming on the code you provide in the question, there is lack of Spring Security Filter, which would authenticate the request. Your security filter chain might be such as:

    @Bean
        public SecurityFilterChain securityFilterChain(HttpSecurity http, MyCustomJwtAuthenticationFilter jwtFilter) throws Exception {
            http.csrf(csrf -> csrf.disable())
                    .authorizeHttpRequests(auth -> auth
                            .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
                            .requestMatchers("/api/game/**").authenticated()
                            .anyRequest().authenticated()
                            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class));
    
            http.sessionManagement(session -> session
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS));
    
            return http.build();
        }
    

    Emphasize your attention on .addFilterBefore, here you will use your filter to authenticate your request.

    Example of implementing JWT: https://medium.com/@tericcabrel/implement-jwt-authentication-in-a-spring-boot-3-application-5839e4fd8fac

    Resource for learning more about Spring Filters: https://docs.spring.io/spring-security/reference/servlet/architecture.html