Spring Security6 + React(Thymeleaf以外) + CSRFで、`Invalid CSRF token found for http://パス`のエラーが発生した場合


こんにちは、hidepon4649 です。
ポートフォリオ作成中に Spring Security6 + React + CSRF でハマったのでメモします。

# 発生したエラー

```エラーログ
Invalid CSRF token found for http://パス”
```

# 構成

デバッグの前後で構成は変えていません。変更したのはソースコードのみ。

```npm.list
├── @emotion/react@11.13.5
├── @emotion/styled@11.13.5
├── @mui/icons-material@6.1.10
├── @mui/material@6.1.10
├── @types/jest@29.5.14
├── @types/node@22.10.1
├── @types/react-dom@18.3.2
├── @types/react@18.3.14
├── axios@0.21.4
├── bootstrap@5.3.3
├── react-dom@18.3.1
├── react-router-dom@6.28.0
├── react-scripts@5.0.1
├── react@18.3.1
└── typescript@4.9.5
```

```build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.0'
    // 〜 中略 〜
}
// 〜 中略 〜
java {
  toolchain {
    languageVersion = JavaLanguageVersion.of(17)
  }
}
// 〜 中略 〜
dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-security:3.4.0' // セキュリティ関連
  implementation 'org.springframework.security:spring-security-config:6.4.1' // セキュリティ関連
  implementation 'org.springframework.security:spring-security-web:6.4.1' // セキュリティ関連
  // 〜 中略 〜
}
// 〜 中略 〜
```

# デバッグ対応

対応内容

- 1 .ログイン処理のリクエストを投げる前に、csrf トークンを取得
- 2 .以降、Http リクエストする際に必ず実施する事が２点
  - 2-1 . ` withCredentials:true` を設定
  - 2-2 . 最初に取得した csrf トークンをヘッダーに設定
- 以下がデバッグ対応後の実装です

## React

```　LoginPage.tsx
import React, { useState } from "react";
import axios from "axios";
import { useNavigate } from "react-router-dom";

const LoginPage = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");
  const navigate = useNavigate();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    try {
      // ログイン処理のリクエストを投げる前に、csrfトークンを取得
      const responseCsrf = await axios.get(
        "http://localhost:8080/api/csrf/token",
        {
          // withCredentials:true を設定
          withCredentials: true
        }
      );
      console.log("CSRF token:", responseCsrf.data.token);
      localStorage.setItem("CSRF-TOKEN", responseCsrf.data.token);

      const response = await axios.post(
        "http://localhost:8080/api/auth/login",
        { email, password },
        {
          // withCredentials:true を設定
          withCredentials: true,
          // 取得したcsrfトークンをヘッダーに設定
          headers: {
            "X-CSRF-TOKEN": localStorage.getItem("CSRF-TOKEN"),
          },
        }
      );
      setError("");
      console.log("Logged in:", response.data);
      localStorage.setItem("token", response.data.token);
      navigate("/attendance");
    } catch (error) {
      console.error("Login failed:", error);
      setError(
        "ログインに失敗しました。ユーザー名とパスワードを確認してください。"
      );
    }
  };

  return (
    <div className="mx-3 mt-3">
      <h2>ログイン</h2>
      <form onSubmit={handleSubmit}>
        <div className="mb-3">
          <label className="form-label" htmlFor="email">
            メールアドレス:
          </label>
          <input
            type="email"
            className="form-control"
            id="email"
            autoComplete="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="メールアドレス"
          />
        </div>
        <div className="mb-3">
          <label className="form-label" htmlFor="password">
            パスワード:
          </label>
          <input
            type="password"
            className="form-control"
            id="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            placeholder="パスワード"
          />
        </div>
        {error && <p className="text-danger">{error}</p>}
        <button type="submit" className="btn btn-primary mx-3 mt-3">
          ログイン
        </button>
      </form>
    </div>
  );
};

export default LoginPage;

```

## Java

```CsrfController.java
package com.example.attendancemanager.controller;

import org.springframework.web.bind.annotation.RestController;

import jakarta.servlet.http.HttpServletRequest;

import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@RestController
@RequestMapping("/api/csrf")
public class CsrfController {

    @GetMapping("/token")
    public CsrfToken csrfToken(HttpServletRequest request) {
        return (CsrfToken) request.getAttribute(CsrfToken.class.getName());
    }

}
```

```SecurityConfig.java
// 〜 中略 〜
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityConfig {

    // 〜 中略 〜
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.ignoringRequestMatchers("/api/csrf/token")) // CSRFトークン取得
                .cors(cors -> cors.configurationSource(corsConfigurationSource()))
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/api/auth/**").permitAll()
                        .requestMatchers("/api/csrf/token").permitAll()
                        .requestMatchers("/api/public/**").permitAll()
                        .requestMatchers("/api/employees/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .exceptionHandling(exception -> exception.authenticationEntryPoint(jwtAuthEntoryPoint))
                .addFilterBefore(jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOrigin("http://localhost:3000");
        config.addAllowedMethod(CorsConfiguration.ALL);
        config.addAllowedHeader(CorsConfiguration.ALL);
        config.setAllowCredentials(true);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
    // 〜 中略 〜
    @Bean
    public AuthenticationManager authManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder authManagerBuilder = http.getSharedObject(AuthenticationManagerBuilder.class);
        authManagerBuilder
                .userDetailsService(userDetailsService)
                .passwordEncoder(passwordEncoder());
        return authManagerBuilder.build();
    }
}
```

以上

私が上述のエラーでハマった際に、
Spring Security6 + React + CSRF の日本語情報がなかなか見つけられなくて無駄に時間を費やしてしまいました。誰かのお役に立てれば幸いです。(私の検索能力が低いだけかも‥？)