0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Spring Security × ReactのAPI認証

Posted at

本記事では、フロントエンド(React × TypeScript)とバックエンド(Spring)におけるWebAPI認証処理について書いていきます。

今回は認証用のJWTトークンをCookieに保存して認証を行う方式で実装します。
ログイン時にサーバーが JWT(アクセストークン)を発行し、これをブラウザの Cookie に保存します。
以降のAPIリクエストではリクエストと一緒にCookieに保存されたJWTトークンも送信されるのでそれをバックエンド側で検証するという仕組みです。
トークンはAPI認証用のアクセストークンとアクセストークン再取得用のリフレッシュトークンの2つを利用します。

アクセストークン リフレッシュトークン
有効期限 短命(数分〜数十分) 長命(数日〜数週間)
用途 API 呼び出し時の認証 アクセストークンの再発行
管理方式 ステートレス(署名検証のみで利用可能) ステートフル(DBに保存し無効化が可能)
強制失効 不可(発行後は有効期限まで有効) 可能(DBから削除すれば即座に無効化できる)
漏洩リスク時の影響 被害は限定的(短時間で期限切れになる) 被害は大きいが、サーバー側で制御・追跡可能
セキュリティ観点 盗まれても短期間で無効化されるので安全性高い サーバー側管理により不正利用時に遮断可能
ユーザー体験 短命なのでそのままでは再ログインが必要になる 再発行により継続的にログイン状態を維持できる

署名検証のみのトークンは検証が楽で高速というメリットはあるが失効不可のため漏洩時のリスクが高い、DB保存でサーバー管理のトークンは漏洩時に失効可能でIPアドレスやデバイスによって識別を行うなどが可能でありセキュリティ性は高いが認証毎にDB通信が必要で通信回数の増加、処理の低速化が起こってしまうというそれぞれのメリットとデメリットがあります。
2つのトークンを利用する理由としては両方の利点を取り入れるためです。
漏洩時のリスクを考えアクセストークンは10分など短命にし、漏洩しても短期間しか利用できないようにします。
そして長命のリフレッシュトークンでアクセストークンの有効期限が切れた際再取得するために使用します。
通常のAPI認証は高速のアクセストークン、アクセストークン再取得の際のみリフレッシュトークンを利用することでユーザー体験を損なわず高セキュリティの認証が可能となります。
認証の流れは以下の図のようになります。
image.png
ログイン成功時にCookieにアクセストークンとリフレッシュトークンを保存、通常のAPI通信ではアクセストークン、アクセストークンが切れた際にはリフレッシュトークンで再取得を行います。


それでは以下に実際のコードを記述して解説していきます。
まずはSpringSecurityの認証処理について書いていきます。

SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig {
	
	@Autowired
	private JwtAuthenticationFilter jwtAuthenticationFilter;
	
	@Bean
	public PasswordEncoder passwordEncoder() {
	    return new BCryptPasswordEncoder();
	}
	
	@Bean
	public AuthenticationManager authenticationManager(
	        AuthenticationConfiguration authConfig) throws Exception {
	    return authConfig.getAuthenticationManager();
	}

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        CsrfTokenRequestAttributeHandler handler = new CsrfTokenRequestAttributeHandler();
        handler.setCsrfRequestAttributeName("_csrf");

        http
        	.csrf(csrf -> csrf
        	      .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        	      .ignoringRequestMatchers("/api/login", "/logout")
        	 )
            .cors(cors -> cors.configurationSource(request -> {
                CorsConfiguration config = new CorsConfiguration();
                config.setAllowedOrigins(List.of("http://localhost:5173"));
                config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
                config.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
                config.setAllowCredentials(true);
                return config;
            }))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/login", "/api/logout").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

各行について詳しく書いていきます。

.csrf(csrf -> csrf
        	      .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
        	      .ignoringRequestMatchers("/api/login", "/logout")
        	 )

csrfを有効化しています
ログイン時はまだクライアントにCSRFトークンが存在せず検証できないため、認証処理をスキップします。
もっとセキュリティを厳密にする場合ログイン時も行ったほうが良いと思います。

.cors(cors -> cors.configurationSource(request -> {
                CorsConfiguration config = new CorsConfiguration();
                config.setAllowedOrigins(List.of("http://localhost:5173"));
                config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
                config.setAllowedHeaders(List.of("Content-Type", "X-XSRF-TOKEN"));
                config.setAllowCredentials(true);
                return config;
            }))

Corsの処理を書いています。
今回はフロント側がhttp://localhost:5173バックエンドがhttp://localhost:8080となっておりそのままではAPI通信をブロックするようになっているため明示的にhttp://localhost:5173のURLからのリクエストのみ許可するという設定です。

  • setAllowedOrigins

    • 許可するURL
  • config.setAllowedMethods

    • 許可するAPI通信のメソッド。今回はすべて許可
  • config.setAllowedHeaders

    • 許可するヘッダーのパラメータ。CSRF検証にX-XSRF-TOKENを使用するため許可
  • config.setAllowCredentials(true);

    • Cookieの使用を許可
.authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/login", "/api/logout").permitAll()
                .anyRequest().authenticated()
            )

login、logout用のAPIのみ権限なしでのアクセスを許可する記述です。

.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

API通信の前に必ず行う認証処理の記述です。
jwtAuthenticationFilterというクラスでアクセストークンの検証を行います。


続いてJWT関連のクラスを以下に記述します。

JwtAuthenticationFilter.java
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
	         throws ServletException, IOException {

     String uri = request.getRequestURI();

     // ログインAPIへのリクエストは認証処理をスキップ
     if (uri.startsWith("/api/login")) {
         filterChain.doFilter(request, response);
         return;
     }
     
     String token = null;

     // クッキー
    if (request.getCookies() != null) {
        for (Cookie cookie : request.getCookies()) {
            if ("ACCESS_TOKEN".equals(cookie.getName())) {
                token = cookie.getValue();
            }
        }
    }

    if (token != null) {
        try {
            Jwt jwt = jwtDecoder.decode(token);

            Authentication auth = new UsernamePasswordAuthenticationToken(
                    jwt.getSubject(), null, List.of());
            SecurityContextHolder.getContext().setAuthentication(auth);

        } catch (JwtException e) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid token");
            return;
        }
    }

    filterChain.doFilter(request, response);

 }

こちらがAPI前の認証処理です。
やってることはCookieからACCESS_TOKENというパラメータの値を取り出してリクエストが認証済みのものか検証しています。
ログイン処理によって付与されたACCESS_TOKENがリクエストパラメータに無いと処理で認証エラーが返されます。

JwtConfig.java
@Configuration
public class JwtConfig {

	private String jwtSecret = "11111111111111111111111111111111"; // 32文字以上のランダムな文字列

	@Bean
	SecretKey secretKey() {
		return new SecretKeySpec(jwtSecret.getBytes(), "HmacSHA256");
	}

	@Bean
	JwtEncoder jwtEncoder(SecretKey secretKey) {
		JWK jwk = new OctetSequenceKey.Builder(secretKey.getEncoded())
				.algorithm(JWSAlgorithm.HS256)
				.build();

		JWKSource<SecurityContext> jwkSource = new ImmutableJWKSet<>(new JWKSet(jwk));
		return new NimbusJwtEncoder(jwkSource);
	}

	@Bean
	JwtDecoder jwtDecoder(SecretKey secretKey) {
		return NimbusJwtDecoder.withSecretKey(secretKey).build();
	}
}

こちらはJwtのエンコーダ、デコーダを定義しています。
定数のjwtSecretを基にjwtトークンを発行、検証するためのエンコーダとデコーダを定義しています。


続いてログイン処理について書いていきます。

AuthenticationController.java
@RestController
public class AuthenticationController {
	
	@Autowired
	private RefreshTokenService refreshTokenService;
		
	@Autowired
	private JwtUtils jwtUtils;
	
	@Autowired
	private CustomerUserDetailsService userDetailsService;

	@PostMapping("/api/login")
    public ResponseEntity<?> login(@RequestBody LoginDto dto, HttpServletResponse response) {
 
        String accessToken = jwtUtils.generateAuthenticationToken(dto.getUserName());
        
        // アクセストークンをCookieに保存
        Cookie accessCookie = new Cookie("ACCESS_TOKEN", accessToken);
	        	accessCookie.setHttpOnly(true);
	        	accessCookie.setPath("/");
	        	response.addCookie(accessCookie);
	        	
    	String refreshToken = UUID.randomUUID().toString();
    	
        Cookie refreshCookie = new Cookie("REFRESH_TOKEN", refreshToken);
        		refreshCookie.setHttpOnly(true);
		        refreshCookie.setPath("/");
		        response.addCookie(refreshCookie);
	
	    return ResponseEntity.ok("Login success");

    }
	
	@PostMapping("/refresh")
	public ResponseEntity<?> refresh(HttpServletRequest request, HttpServletResponse response, Authentication auth) {
	    String refreshTokenPlain = Arrays.stream(request.getCookies())
	        .filter(c -> "REFRESH_TOKEN".equals(c.getName()))
	        .findFirst()
	        .map(Cookie::getValue)
	        .orElse(null);
	    
	    String userName = auth.getName();

	    if (refreshTokenPlain != null && refreshTokenService.validateRefreshToken(userName, refreshTokenPlain)) {
	        // 新しいトークンを発行
	        String newAccessToken = jwtUtils.generateAuthenticationToken(auth.getName());
	        String newRefreshToken = UUID.randomUUID().toString();

	        refreshTokenService.revokeTokens(userName); // 古いリフレッシュを無効化
	        refreshTokenService.saveRefreshToken(userName, newRefreshToken);

	        // Cookieに再設定
	        response.addCookie(new Cookie("ACCESS_TOKEN", newAccessToken));
	        response.addCookie(new Cookie("REFRESH_TOKEN", newRefreshToken));

	        return ResponseEntity.ok("トークン更新成功");
	    }

	    return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("リフレッシュトークン無効");
	}
}
LoginDto.java
@Data
public class LoginDto {
	
	private String userName;
	private String password;

}

api/loginでリクエストが送られるとメソッドloginが呼び出されます。
本来はこの中でユーザーIDとパスワードの検証を行うのですが、今回はJWT認証処理がメインのため省略します。

String accessToken = jwtUtils.generateAuthenticationToken(dto.getUserName());
        
// アクセストークンをCookieに保存
Cookie accessCookie = new Cookie("ACCESS_TOKEN", accessToken);
        accessCookie.setHttpOnly(true);
        accessCookie.setPath("/");
        response.addCookie(accessCookie);

この部分でアクセストークンを生成しています。
ユーザー名からアクセストークンをjwtキーを使用し生成、検証の時もユーザー名をjwtのキーを使ってデコードしています。
これによりサーバー側はセッションを持たずにリクエストごと認証を行えるので、DB通信などの処理が実行されずステートレスな通信が可能となります。

String refreshToken = UUID.randomUUID().toString();
    	
Cookie refreshCookie = new Cookie("REFRESH_TOKEN", refreshToken);
        refreshCookie.setHttpOnly(true);
        refreshCookie.setPath("/");
        response.addCookie(refreshCookie);

一方こちらがリフレッシュトークンの生成箇所です。
リフレッシュトークンはランダムな文字列からトークンを生成し、その値をDBに保存します。
サーバー側でトークンを管理することによって、トークンをサーバー側で無効にすることが可能です。

メソッドrefreshではリフレッシュトークンが有効だった場合にアクセストークン、リフレッシュトークンを再発行しています。
これにより、アクセストークンの有効期限を短く設定してもユーザーはシームレスに新しいトークンを受け取れるため、利便性を損なうことなくセッションを継続できます。また、リフレッシュトークンはサーバー側で管理しているため、不正利用が検知された場合には失効させることで強制的にログアウトさせることも可能になり、セキュリティ面でも安全性を高められます。

リフレッシュトークンを登録するためのDB操作はJPAを使用して行っています。

RefreshTokenService.java
@Service
public class RefreshTokenService {

	@Autowired
    private TokenRepository tokenRepo;

    public boolean validateRefreshToken(String userName, String refreshTokenPlain) {
        return tokenRepo.findByUserNameAndRevokedFalse(userName)
            .filter(rt -> rt.getExpiresAt().isAfter(LocalDateTime.now()))
            .filter(rt -> BCrypt.checkpw(refreshTokenPlain, rt.getTokenHash())) // ハッシュ照合
            .isPresent();
    }

    public TokenEntity saveRefreshToken(String userName, String refreshTokenPlain) {
        TokenEntity rt = new TokenEntity();
        rt.setUserName(userName);
        rt.setTokenHash(BCrypt.hashpw(refreshTokenPlain, BCrypt.gensalt()));
        rt.setIssuedAt(LocalDateTime.now());
        rt.setExpiresAt(LocalDateTime.now().plusDays(30));
        return tokenRepo.save(rt);
    }

    public void revokeTokens(String userName) {
        tokenRepo.findByUserNameAndRevokedFalse(userName).ifPresent(rt -> {
            rt.setRevoked(true);
            tokenRepo.save(rt);
        });
    }

}
TokenRepository.java
@Repository
public interface TokenRepository extends JpaRepository<TokenEntity, Long> {
	Optional<TokenEntity> findByUserNameAndRevokedFalse(String userName);

}
TokenEntity.java
@Entity
@Table(name = "token")
@Data
public class TokenEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "token_id")
    private Integer tokenId; // SQL Server int に対応

    @Column(name = "user_name", nullable = false, length = 50)
    private String userName;

    @Column(name = "token_hash", nullable = false, length = 255)
    private String tokenHash;

    @Column(name = "issued_at", nullable = false)
    private LocalDateTime issuedAt;

    @Column(name = "expires_at", nullable = false)
    private LocalDateTime expiresAt;

    @Column(name = "revoked", nullable = false)
    private Boolean revoked;
}

続いてフロント側の処理を記載します。

Login.tsx
import React, { useState } from "react";

const Login = () => {
  const [userName, setUserName] = useState("");
  const [password, setPassword] = useState("");

  const handleLogin = async () => {
    await fetch("http://localhost:8080/api/login", {
      method: "POST",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ userName, password }),
    });
  };

  return (
    <>
      <p>ユーザー名</p>
      <input
        type="text"
        value={userName}
        onChange={(e) => setUserName(e.target.value)}
      />
      <p>パスワード</p>
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <div>
        <button onClick={handleLogin}>Login</button>
      </div>
    </>
  );
};

export default Login;

Loginボタンを押すと先ほどControllerで定義した/api/loginにリクエストが送られます。

API呼び出し確認のためテスト用APIをcontrollerに追加し画面から呼び出してみます。

@GetMapping("/api/test")
public ResponseEntity<?> admintTest(HttpServletResponse response) {
    return ResponseEntity.ok("Test content");
}
api.tsx
// api.ts
import axios from "axios";

const api = axios.create({
  baseURL: "http://localhost:8080",
  withCredentials: true, // Cookie を送る
  headers: {
    "Content-Type": "application/json",
  },
});

// レスポンスインターセプター
api.interceptors.response.use(
  (response) => response, // 成功時はそのまま返す
  async (error) => {
    const originalRequest = error.config;

    // 401 が返ってきたら refresh API を試す
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true; // 無限ループ防止

      try {
        await api.post("/refresh"); // リフレッシュAPI呼び出し
        // リフレッシュ成功したら元のリクエストを再送
        return api(originalRequest);
      } catch (refreshError) {
        console.error("Refresh failed. Please login again.");
        // ここでログイン画面にリダイレクトなど
      }
    }

    return Promise.reject(error);
  }
);

export default api;
Home.tsx
import React from "react";
import api from "./api";

const HOME = () => {
  const functionTest = async () => {
    try {
      const response = await api.get("/api/test");
      console.log("API result:", response.data);
    } catch (error) {
      console.error("API call failed:", error);
    }
  };

  return (
    <div>
      <h1>Home</h1>
      <button onClick={functionTest}>Test Function</button>
    </div>
  );
};

export default HOME;

開発者ツールで確認すると以下のようにCookieに認証情報がセットされてるのが分かります。
cookie.png

試しにCookieを削除しAPIを呼び出してみると以下のように認証エラーとなります

image.png

今のままでは全画面を認証前に開けてしまうためreact-routerで画面遷移前にAPIを呼び出すようにしてあげれば認証後のみ画面起動が可能になる制御を行うことができます。
loaderにAPI呼び出しの処理を記述すればAPIリクエストが200の時以外は画面起動不可となります。

App.tsx
import "./App.css";
import { createBrowserRouter, RouterProvider } from "react-router";
import Login from "./Login";
import HOME from "./home";
import api from "./api";

function App() {
  const router = createBrowserRouter([
    {
      path: "/login",
      element: <Login />,
    },
    {
      path: "/HOME",
      element: <HOME />,
      // 画面起動チェックAPI
      loader: async () => {
        api.get("/api/auth").then((res) => {
          console.log("Auth check:", res.data);
        });
      },
    },
  ]);

  return (
    <>
      <RouterProvider router={router} />
    </>
  );
}

export default App;
@GetMapping("/api/auth")
public ResponseEntity<?> adminAccess() {
    return ResponseEntity.ok("Admin content");
}

以上がSpring Security × ReactのAPI認証全容になります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?