【備忘録】Spring SecurityとJWTを使った認証・認可機能の実装

## はじめに

Spring SecurityとJWTを組み合わせた認証・認可機能を実装するときの流れをまとめる。
今回はDaoAuthenticationProviderを用いて、データベースからユーザー情報を取得し、
JWTを使って認証状態を管理する。

## Spring Securityでのリクエストの処理フロー

Spring Securityでは、リクエストが複数のフィルターを通過する。
これらのフィルターが順番にリクエストを処理し、必要な認証や認可を行う。
処理のシナリオとしては以下のようになる。

### 1. ログイン時の処理
**UsernamePasswordAuthenticationFilter**がリクエストを処理。
↓
フォームデータからユーザー名とパスワードを抽出。
↓
**DaoAuthenticationProvider**がデータベースからユーザー情報を取得し、パスワードを検証。
↓
認証成功時、JWTを生成しクライアントに返却。
↓
クライアントはJWTを保持し、次回以降のリクエストで使用。
### 2. JWTを持った状態でのリクエスト処理
**JwtAuthenticationFilter**がリクエストを処理。
↓
リクエストヘッダーからJWTを抽出し、検証。
↓
トークンが有効であれば、ユーザー情報を抽出し、SecurityContextHolderに設定。
↓
**UsernamePasswordAuthenticationFilter**はスキップされ、リクエストが正常に処理される。
↓
認証が必要なエンドポイントにアクセス可能。

## 実装
### JwtAuthenticationFilter
Jwtを使用する場合は独自のフィルターを作成する必要がある。
フィルターは以下のように作成した。
```Java
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {

    try {
        // リクエストからJWTを取得
        String jwt = jwtTokenProvider.getJwtFromReuest(request);

        // JWTが存在し、有効であれば処理を続ける
        if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
            // JWTからユーザー名を取得
            String username = jwtTokenProvider.getUsernameFromJwt(jwt);
            // UserDetailsをuserDetailsServiceから取得
            UserDetails user = userDetailsService.loadUserByUsername(username);
            // ユーザーの情報を持つAuthenticationオブジェクトを作成
            UsernamePasswordAuthenticationToken auth = 
                new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
            // SecurityContextにAuthentication情報を設定
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        // フィルターチェーンを続行
        filterChain.doFilter(request, response);
    } catch (RuntimeException ex) {
        // RuntimeExceptionが発生した場合、セキュリティコンテキストをクリア
        SecurityContextHolder.clearContext();
        // エラーレスポンスを処理
        handleErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED, ex.getMessage());
    } catch (Exception e) {
        // その他の例外が発生した場合、セキュリティコンテキストをクリア
        SecurityContextHolder.clearContext();
        // 一般的なエラーレスポンスを処理
        handleErrorResponse(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "An unexpected error occurred");
    }
}
```

上記によって、JWTが存在し有効であれば認証処理が行われ、次のフィルターへと移る。
JWTが存在しないまたは無効の場合は、認証処理がスキップされ、filterChain.doFilter(request, response)が呼び出されることになる。この時、次のフィルターへ移行し、JWTを持たないユーザーは、認証が必要なエンドポイントにアクセスできないように制限される。

### SecurityConfig

作成したフィルターをフィルターチェーンに追加する。
SecurityConfigを作成し、以下を記述する。

```Java
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationFilter jwtAuthenticationFilter;
    
    @Autowired
    private CustomUserDetails userDetails;

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf(csrf->csrf.disable())
        // Jwtを使用する場合は、リクエストごとにセッションを確立する必要はない。
        .sessionManagement(session->session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        // "/api/auth/**"は認証時に使用するエンドポイントのため許可、
        // その他のエンドポイントへのリクエストは認証が必要とした。
        .authorizeHttpRequests(auth->auth.requestMatchers("/api/auth/**").permitAll()
        .anyRequest().authenticated())
        // jwtAuthenticationFilterを先に配置することで、リクエストごとにJWTを解析して認証を済ませることができる。
        .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    AuthenticationManager authenticationManager(HttpSecurity http) throws Exception {
        AuthenticationManagerBuilder auth = http.getSharedObject(AuthenticationManagerBuilder.class);
        // AuthenticationProviderとしてDaoAuthenticationProviderを使用する
        auth.authenticationProvider(authenticationProvider());
        return auth.build();
    }
    
    @Bean
    DaoAuthenticationProvider authenticationProvider() {
    	DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
    	authProvider.setUserDetailsService(userDetails);
    	authProvider.setPasswordEncoder(passwordEncoder());
    	return authProvider;
    }

    @Bean
    PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

```

JWTを持ったリクエストがログインフィルターの前に処理されるようにaddFilterBeforeメソッドを使用する。これによって、UsernamePasswordAuthenticationFilterの前に処理されるようにしている。

### JwtUtil
Jwtで使用するメソッドをまとめたクラスを作成する。

```Java
@Component
public class JwtTokenProvider {


	@Value("${security.jwt.token.expiration-ms}")
	private Long jwtExpirationMs;

	@Value("${security.jwt.token.secretkey}")
	private String jwtSecretKey;

	private Key getSignWithKey(){
		byte[] decodedKey = Decoders.BASE64.decode(jwtSecretKey);
		return Keys.hmacShaKeyFor(decodedKey);
	}

	public String getUsernameFromJwt(String token){
		return Jwts.parserBuilder()
				.setSigningKey(getSignWithKey())
				.build()
				.parseClaimsJws(token)
				.getBody()
				.getSubject();
	}

	public long getExpirationMs(){
		return jwtExpirationMs;
	}

	public boolean validateToken(String token){
		try {
			// parseClaimsJws(token)でtokenを検証してくれる。
			// もし失敗したらJwtExceptionがthrowされる
			Jwts.parserBuilder().setSigningKey(getSignWithKey()).build().parseClaimsJws(token);
			return true;
		} catch (JwtException | IllegalArgumentException e) {
			throw new CustomException("Jet error","Expired or invalid JWT token",HttpStatus.UNAUTHORIZED);
		}
	}

	
	public String getJwtFromReuest(HttpServletRequest req){
		String bearerToken = req.getHeader("Authorization");
		// Tokenがnullでなく、"Bearer"から始まっていれば"Bearer "を取り除く
		if(bearerToken != null && bearerToken.startsWith("Bearer ")){
			return bearerToken.substring(7);
		}
		return null;
	}


	public String generateToken(Authentication authentication){
		// 認証情報からusernameを取ってくる
		String username = authentication.getName();
		// ペイロードのissに現在のDateをセットするために使用する
		Date now = new Date();
		// ペイロードのexpに現在のDateにexpiration-msの値を足した値をセットするために使用する
		Date expiryDate = new Date(now.getTime() + jwtExpirationMs);

		return Jwts.builder()
				.setSubject(username)
				.setIssuedAt(now)
				.setExpiration(expiryDate)
				.signWith(getSignWithKey(), SignatureAlgorithm.HS512)
				.compact();
	}
}
```

### Controller、Service
以下のようなcontroller、serviceを作成する。

 ```Java
 //ログイン、サインアップ時のエンドポイント
 @RestController
@RequestMapping("/api/auth")
public class AuthController {
    @Autowired
    private AuthService authService;

    @PostMapping("/signup")
    public ResponseEntity<User> signup(@Valid @RequestBody SignupRequestDto signupRequestDto) {
        User signupUser = authService.signup(signupRequestDto);
        return ResponseEntity.ok(signupUser);
    }

    @PostMapping("/login")
    public ResponseEntity<ResponseDto> login(@RequestBody LoginRequestDto loginRequestDto) {
        
        ResponseDto response = authService.authenticateUser(loginRequestDto);
        return ResponseEntity.ok(response);
    }    
}

//認証後にアクセス可能なエンドポイント
@RestController
@RequestMapping("/home")
public class HomeController {
	@GetMapping
	public String hello(@AuthenticationPrincipal UserDetails userDetails) {
		return "hello, " + userDetails.getUsername();
	}
}
 ```


 ```Java
 @Service
public class AuthService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private JwtTokenProvider jwtTokenProvider;

    public User signup(SignupRequestDto signupRequestDto){
        String username = signupRequestDto.getUsername();
        String email = signupRequestDto.getEmail();
        String hashedPassword = passwordEncoder.encode(signupRequestDto.getPassword());
        if(userRepository.existsByUsername(username)){
            throw new CustomException("Conflict","this username is already in use!",HttpStatus.CONFLICT);
        }

        if(userRepository.existsByEmail(email)){
            	throw new CustomException("Conflict","this email is already in use!",HttpStatus.CONFLICT);
        }

        User user = new User();
        user.setUsername(username);
        user.setEmail(email);
        user.setPassword(hashedPassword);

        return userRepository.save(user);
    }

    public ResponseDto authenticateUser(LoginRequestDto loginRequestDto){
        try {
            // DaoAuthenticationProviderでの認証
            Authentication auth = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                    loginRequestDto.getUsername(), loginRequestDto.getPassword()
                )
            );
            SecurityContextHolder.getContext().setAuthentication(auth);
            // 認証成功時にJwtを生成しクライアントへ返す
            String jwt = jwtTokenProvider.generateToken(auth);

            ResponseDto response = new ResponseDto();
            response.setToken(jwt);
            response.setExpiresIn(jwtTokenProvider.getExpirationMs());
            return response;

        } catch (AuthenticationException e) {
            // 認証失敗時の処理
            throw new CustomException("Authentication Error","Invalid username or password",HttpStatus.UNAUTHORIZED);
        } 
    }
}
 ```

## デモ
signupでユーザを作成後、localhost:8080/api/auth/loginに作成したユーザ情報でリクエストを送る。
```
例：
{
    "username":"user",
    "password":"password"
}
```
すると以下のようにJwtトークンが返ってくる。
```
{
    "token": "eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJ1c2VydXNlcjIyMiIsImlhdCI6MTczMDgzNTk1MiwiZXhwIjoxNzMwODM2MjUyfQ.X1DtzgHlA9_5JD00Reqxoj2gZi6up8BPERpb2aNxnPMMEQdPoiCSIQqxCNB80cYiiDFZrz9Y9cI1dmVIZK7kZQ",
    "expiresIn": 300000
}
```

上記のトークンをAuthorizationに設定し、localhost:8080/homeにリクエストを送る。
すると、以下の通りログインしたユーザ情報を持つレスポンスが返ってくる。
```
hello, user
```

## 参考
以下の記事および動画を参考にさせていただいた。
https://www.codeflow.site/ja/article/spring-security-authentication-with-a-database
https://medium.com/@tericcabrel/implement-jwt-authentication-in-a-spring-boot-3-application-5839e4fd8fac
https://www.youtube.com/watch?v=KxqlJblhzfI