Spring Boot: Endpoint kann nicht gefunden werden (404)

RezaScript

Bekanntes Mitglied
Hallo, ich bin mit Spring Security nicht wirklich vertraut, daher hoffe ich, dass mir jemand weiterhelfen kann.

Meine Website, example.com, greift auf einige Endpoints auf api.example.com zu. Siehe hier:

network.png

Ich verstehe nicht, warum ich bei den Requests zuerst ein 200 Status bekomme und danach aber 404. So sieht z.B. eine Response aus:

JSON:
{
    "timestamp": "2024-10-29T12:18:57.892+00:00",
    "status": 404,
    "error": "Not Found",
    "message": "No message available",
    "path": "/user-profile/auth-data"
}

Die 404-Meldung erhalte ich nicht nur über Browser, sondern auch via Postman.

So sieht z.B. ein Controller aus:

Java:
@RestController
@RequestMapping("/user-profile")
@AllArgsConstructor
public class UserProfileController {
    private UserProfileService userProfileService;
    private UploadService uploadService;
    private Jwt jwt;

    /**
     * Retrieves profile data
     * @param authorizationHeader Authorization to obtain user's token
     * @return profile data, such as name and profile picture
     */
    @GetMapping("/auth-data")
    public ResponseEntity<Response> getAuthData(@RequestHeader("Authorization") String authorizationHeader) {
        String token = authorizationHeader.substring(7);
        UserProfileAuthData data = userProfileService.getAuthData(token);
        if (data != null) {
            Response response = Response.builder()
                    .code("USER_PROFILE_DATA")
                    .status(HttpStatus.OK)
                    .timestamp(LocalDateTime.now())
                    .messages(List.of(data))
                    .build();
            return ResponseEntity.status(HttpStatus.OK).body(response);
        }
        Response response = Response.builder()
                .code("NO_DATA")
                .status(HttpStatus.NOT_FOUND)
                .timestamp(LocalDateTime.now())
                .messages(List.of("No data could be found."))
                .build();
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
    }

Und so sieht meine Konfiguration aus:

Java:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@AllArgsConstructor
@SpringBootApplication(scanBasePackages = {"com.dynamicquotation.dq"})
public class SecurityConfiguration implements WebMvcConfigurer {
    private final JwtFilter jwtFilter;
    private final DataSource dataSource;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.cors(cors -> cors.configurationSource(request -> {
                    CorsConfiguration configuration = new CorsConfiguration();
                    configuration.setAllowedOrigins(List.of("https://example.com", "https://accounts.google.com"));
                    configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
                    configuration.setAllowedHeaders(List.of("*"));
                    configuration.setExposedHeaders(List.of("Authorization")); 
                    configuration.setAllowCredentials(true);
                    return configuration;
                }))
                .csrf(AbstractHttpConfigurer::disable);

        http.sessionManagement(sess -> sess.sessionAuthenticationStrategy(sessionAuthenticationStrategy()));

        // Authorization
        http.authorizeHttpRequests(auth ->
                auth
                        .requestMatchers("/auth/login", "/auth/is-token-valid", "/logout", "/contact", "/quotation/single-quotation/**").permitAll()
                        .anyRequest().authenticated()
        );

        http.oauth2Login(Customizer.withDefaults());
        http.httpBasic(withDefaults());

        // logout
        http.logout(l -> l
                .logoutUrl("/logout")
                .logoutSuccessUrl("/")
                .clearAuthentication(true)
                .deleteCookies("JSESSIONID")
                .invalidateHttpSession(true)
                .logoutRequestMatcher(new AntPathRequestMatcher("/logout"))
                .logoutSuccessHandler((request, response, authentication) -> response.setStatus(HttpServletResponse.SC_OK))
                .addLogoutHandler((request, response, authentication) -> {
                    // Additional logout handler
                }).permitAll());

        http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

Wie wir sehen, funktioniert der Endpoint "/auth/is-token-valid" ziemlich gut, da er erlaubt ist. Aber z.B. "/auth-data" sollte nicht erlaubt sein, da der User vorerst eingeloggt sein muss. Die 404-Meldungen erhalte ich, obwohl ich mich via OAuth (Google) einlogge.

Derselbe Code funktioniert auf localhost ziemlich gut. Der Fehler taucht nur auf dem Live-Server auf.

Was mache ich falsch?
 
Gibt: userProfileService.getAuthData(token); NULL zur
Soweit kommt es gar nicht, denn der Controller wird gar nicht aufgerufen.

Darf denn der "Live-Server" auf die entsprechenden URL's zugreifen? Ich frage nur, weil man bei uns jede URL explizit freischalten muss.
Ja, an und für sich schon. Die Konfigurationen liegen lediglich in SecurityFilterChain und so sieht meine Nginx-Konfiguration aus:

Bash:
server {
    listen 443 ssl;
    server_name api.example.com;

    ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;

    location / {
        # Proxy pass to Spring Boot application
        proxy_pass http://127.0.0.1:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}
 
Hier sind die Controller zum Vergleich:

Java:
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
    @GetMapping("/is-token-valid")
    public ResponseEntity<Response> isTokenValid(@RequestParam("token") String token) { // Status 200

Java:
@RestController
@RequestMapping("/user-profile")
@AllArgsConstructor
public class UserProfileController {
    @GetMapping("/auth-data")
    public ResponseEntity<Response> getAuthData(@RequestHeader("Authorization") String authorizationHeader) { // Status 404

Java:
@RequestMapping("/quotation")
@RestController
public class QuotationController {
    @GetMapping(value = "/list")
    public List<AllQuotationsResponse> listQuotations(@ModelAttribute("token") String token) { // Status 404

Also ich habe herausgefunden, dass es an @RequestHeader("Authorization") und @ModelAttribute("token") liegt. Denn wenn ich die Parameter entferne, werden die Controller aufgerufen.

Gut, nun weiss ich wo der Fehler liegt, verstehe aber trotzdem nicht, warum es so ist wie es ist, denn mit configuration.setAllowedHeaders(List.of("*")); erlaube ich ja alle Art von Headers. Die Parameter brauche ich, um den Token zu empfangen. Wie löse ich das?
 
Ist irgendwas anderes noch vor dem Nginx geschaltet? Also ein ApplicationGateway mit irgendwelchen Firewall-Regeln?
Nein, gar nichts.

Also in der Zwischenzeit habe ich festgestellt, dass mein Code ziemlich ein Schrott ist.
Begründung: Spring Boot generiert ein Token (JWT) und sendet es an Angular und Angular speichert es dann als Cookie ab. Bei jedem Request sendete dann Angular den Wert des Cookies als Authorization Header an API. Somit gilt dieses Cookie als eine Sicherheitslücke.

Ich habe den Code nun so umgeschrieben, dass das Token nie ans Frontend gesendet wird. Stattdessen erstellt Spring Boot selbst das Cookie. Nun habe ich ein Cookie mit den Eigenschaften HttpOnly, Secure und SameSite.

cookies.png

Ich habe nun aber das Problem, dass ich den Wert des Cookies zwar auf localhost auslesen kann, aber nicht auf dem Production-Server.

Java:
@AllArgsConstructor
@Component
public class JwtFilter extends OncePerRequestFilter {
    private final CustomUserService customUserService;
    private Jwt jwt;

    @Override
    protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
        final String authorizationHeader = request.getHeader("Authorization");
        String token = null;
        String username = null;

        if (request.getCookies() != null) {
            for (Cookie cookie : request.getCookies()) {
                if ("jwt".equals(cookie.getName())) {
                    token = cookie.getValue();
                    try {
                        jwt.isTokenValid(token, customUserService.loadUserByUsername(jwt.extractUsername(token)));
                    }
                    catch (Exception e) {
                        Response responseObj = Response.builder()
                                .code("UNAUTHORIZED")
                                .status(HttpStatus.UNAUTHORIZED)
                                .timestamp(LocalDateTime.now())
                                .messages(Collections.singletonList("Invalid token"))
                                .build();
                        ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
                        String json = mapper.writeValueAsString(responseObj);
                        response.setContentType("application/json");
                        response.setCharacterEncoding("UTF-8");
                        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                        response.getWriter().write(json);
                        return;
                    }
                    username = jwt.extractUsername(token);
                }
            }
        }
        System.out.println("Cookie: " + token);
        System.out.println("Username: " + username);

        if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
            token = authorizationHeader.substring(7);
            // Check if token is valid
            try {
                jwt.isTokenValid(token, customUserService.loadUserByUsername(jwt.extractUsername(token)));
            }
            catch (Exception e) {
                Response responseObj = Response.builder()
                        .code("UNAUTHORIZED")
                        .status(HttpStatus.UNAUTHORIZED)
                        .timestamp(LocalDateTime.now())
                        .messages(Collections.singletonList("Invalid token"))
                        .build();
                ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
                String json = mapper.writeValueAsString(responseObj);
                response.setContentType("application/json");
                response.setCharacterEncoding("UTF-8");
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write(json);
                return;
            }
            username = jwt.extractUsername(token);
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = customUserService.loadUserByUsername(username);

            if (jwt.isTokenValid(token, userDetails)) {
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        filterChain.doFilter(request, response);
    }
}

Auf localhost bekomme ich die Werte von token und username, auf dem Production-Server sind sie aber beide null. Warum?
 
Somit gilt dieses Cookie als eine Sicherheitslücke.
Ich habe keine wirkliche Ahnung von JWT. Hast du einen Link oder Quelle dazu. Man lernt ja nie aus...

ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
Ich denke das solltest du nicht so machen. Es gibt dafür schon Beans und falls die nicht passen kannst du selbst eine dafür erstellen.

Weiterhin finde ich, dass du zu viel Code schreibst. Exceptions kann man zentral handhaben und dort immer einheitlich klar definierte Responses bauen. Gleiches gilt für die Rückgabe von Objekten/Modellen/JSON. Sieh das bitte nicht als Kritik. Für meinen Geschmack wäre das zu viel Code zu lesen und zu prüfen, bspw. wenn man da mal wieder vorbeikommt oder dafür Tests schreiben soll.
 
Bei RestController und JWT hatte ich mal (kann mich leider nur diffus erinnern), dass Spring den Request STATELESS gehalten hat. Man musste in der SecurityFilterChain explizit das Session Management (Cookies) 'einschalten'. Irgendwie so:
Java:
http.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));

Statt IF_REQUIRED evtl. auch STATELESS, dann muss das Token bei jedem Request mitgesendet werden, kommt aber der REST Philosophie näher.
 
Zuletzt bearbeitet:
http.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));
Ich benutze bereits:
Java:
http.sessionManagement(sess -> sess.sessionAuthenticationStrategy(sessionAuthenticationStrategy()));

Wenn ich es durch
Java:
http.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
ersetze oder den Code zusätzlich hinzufüge, führt es irgendwie zur ERR_TOO_MANY_REDIRECTS und token und username bleiben nach wie vor null.

Ich habe keine wirkliche Ahnung von JWT. Hast du einen Link oder Quelle dazu. Man lernt ja nie aus...
Mit JWT meine ich JSON Web Tokens. Mehr dazu findest du hier: https://jwt.io/introduction

Weiterhin finde ich, dass du zu viel Code schreibst. Exceptions kann man zentral handhaben und dort immer einheitlich klar definierte Responses bauen. Gleiches gilt für die Rückgabe von Objekten/Modellen/JSON. Sieh das bitte nicht als Kritik. Für meinen Geschmack wäre das zu viel Code zu lesen und zu prüfen, bspw. wenn man da mal wieder vorbeikommt oder dafür Tests schreiben soll.
Klar, gebe ich dir recht. Ich habe sogar ein Package mit nur Exceptions. Den Code muss ich sowieso am Schluss optimieren, aber vorerst muss er funktionieren.

In meinem dritten Beitrag habe ich erwähnt, dass der erste Controller funktioniert, die anderen zwei aber nicht. Controller 1 enthält @RequestParam("token"). Das ist lediglich ein Parameter in URL und daher funktioniert es gut. Bei den anderen zwei handelt es sich um Headers, die offenbar nicht gesendet oder angenommen werden. Jedenfalls sind diese Werte immer null, genauso wie der Cookie. Es scheint also, dass ich generell ein Problem mit Headers habe. Ich möchte gerne nochmals andeuten, dass ich dieses Problem auf localhost nicht habe. Offenbar liegt es daran, dass API und frontend unterschiedliche Domains haben.
 
Ich habe keine wirkliche Ahnung von JWT. Hast du einen Link oder Quelle dazu. Man lernt ja nie aus...
Das JWT (JSON Web Token) alleine bringt Dir nicht viel. Das ist im Wesentlichen ein JSON-Dokument, das sicherheitsrelevante Informationen enthält. Interessant wird das erst im Zusammenspiel mit OAuth bzw. OpenID Connect (OIDC).

 
Das JWT (JSON Web Token) alleine bringt Dir nicht viel. Das ist im Wesentlichen ein JSON-Dokument, das sicherheitsrelevante Informationen enthält. Interessant wird das erst im Zusammenspiel mit OAuth bzw. OpenID Connect (OIDC).

Ich weiß im Prinzip was JWT ist und wie es eingesetzt wird. Mir ist nur nicht klar, warum das Token als Cookie eine Sicherheitslücke sein soll. Cookie's sind im Endefekt nichts anderes als ein HTTP-Header, welche bei der Übertragung ebenfalls verschlüsselt werden. Wenn das so wäre, wäre dann nicht jede Cookie-Bassierte-Anmeldung eine Sicherheitslücke? Ich hatte gehofft eine Begründung dafür zu erhalten.
 
Mir ist nur nicht klar, warum das Token als Cookie eine Sicherheitslücke sein soll.
Cookies gelten als Sicherheitslücke, da sie via XXS vom Aussen ausgelesen werden könnten. Besonders Tokens sind davon stark betroffen, da diese gar nicht gelöscht werden können, sondern man lediglich abwarten muss, bis sie ablaufen. In meinem Fall lag der Fehler darin, dass ich das Token ans Frontend sendete und das Frontend erstelle dann das Cookie. Um das Cookie sicherer zu machen, erstelle ich das Cookie nun direkt übers Backend. Das hat den Vorteil, dass das Cookie noch die Attribute Secure und HttpOnly enthält. D.h. die Verbindung läuft nur über HTTPS und das Cookie kann via JavaScript nicht ausgelesen werden.
 
Cookies und HTTP-Header sind nun mal ein unvermeidliches Übel - bei den meisten Loginverfahren. Scott Hanselman meinte früher mal zu HTTPOnly-Cookies: "slowing down the average script kiddie for 15 seconds" - natürlich sind die Browser heute viel sicherer als 2005. Man sollte aber natürlich alle verfügbaren Möglichkeiten ausschöpfen. Eigentlich mache ich mir zu XXS weniger Sorgen, es gibt heute eine ganze Reihe Mechanismen dagegen. Allerdings gibt es auch immer noch manchmal diese katastrophalen Hobby-PHP-Seiten. Da kann man mal wieder üben 😉
 

Zurück
Oben