Spring Security JWT 구성

JWT 구성 도전하기

JWT에 대한 이해

JWT에 대한 기본적인 구조는 검색하면 나오기에 차치하고 대표적인 흐름만 살펴보자면 다음과 같다.

  1. 액세스 토큰이 유효하다 - 정상 처리
  2. 액세스 토큰이 만료되었다 - 리프레시 토큰을 이용해 새 액세스 토큰 발급
  3. 리프레시 토큰이 만료되었는가 - 로그아웃 하고 재로그인 유도

그래서 기본적으로 JWT를 구성할 때는 위 흐름을 기본으로 구성하는 것으로 이해했다. 시큐리티 상에서는 이 흐름이 필터에서 구현되며 기본적으로 모든 요청에 대해 검사하여 위 흐름을 태우도록 했다.

토큰 유틸 클래스 구성

JWT 토큰 구성의 경우, 기본적으로 여러가지 예제 케이스들이 있고 GPT에게 물어보면 답을 해주기에 글을 위해 기본적으로 구성했다. 실제로는 키라던가 솔트 값 등 더 보안적으로 보완한 로직을 사용했지만 여기서는 예제로 구성했다.

package com.example.demo.util;

import org.springframework.stereotype.Component;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtTokenUtil {

    private final SecretKey secretKey;
    private final long jwtExpirationMs = 1000 * 60 * 15; // 15분
    private final long refreshExpirationMs = 1000L * 60 * 60 * 24 * 7; // 7일

    public JwtTokenUtil() {
        // 최소 256비트(32바이트) 이상 키 필요
        this.secretKey = Keys.hmacShaKeyFor("your-very-secret-key-which-is-at-least-32-characters".getBytes());
    }

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

    public String generateRefreshToken(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + refreshExpirationMs))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }

    public String generateExpiredToken(String username) {
        return Jwts.builder()
                .setSubject(username)
                .setIssuedAt(new Date(System.currentTimeMillis() - 1000 * 60 * 60)) // 발급 시간: 1시간 전
                .setExpiration(new Date(System.currentTimeMillis() - 1000 * 60)) // 만료 시간: 1분 전
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }


    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (JwtException e) {
            return false;
        }
    }

    public String getUsernameFromToken(String token) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(secretKey)
                    .build()
                    .parseClaimsJws(token)
                    .getBody()
                    .getSubject();
        } catch (ExpiredJwtException e) {
            return e.getClaims().getSubject(); // 👈 만료된 토큰이라도 subject 꺼낼 수 있음
        }
    }

}

기본적으로 토큰 유틸에서는 다음과 같은 작업을 수행하는 메소드를 위치시킨다.

  1. 액세스 토큰 발급
  2. 리프레시 토큰 발급
  3. 토큰 검사 메소드
  4. 토큰에서 정보 추출하는 메소드

이제 이를 통해 토큰을 관리할 수 있다.

실제 요청에 대해 토큰을 검사하는 필터 구현

실제 필터에서는 위 흐름에 맞게 로직을 구성한다. 사용자의 액세스 토큰 및 리프레시 토큰은 로그인 과정에서 발급하고 세팅하고 여기서는 필터만 우선적으로 구현했다.

단, 여기서 여러가지 문제가 발생했는데 다음과 같다.

모든 요청에 대해 검사를 할 것인가?

실제로 모든 요청에 대해 검사를 하는 것은 비즈니스 상황에서 할 수 없다. 왜냐하면 전체 공개가 필요한 구간도 있고 무엇보다 리소스를 요청하는 예를 들어, html, css, js 등을 요청하는 것도 있기 때문이다.

리소스 요청의 경우 아예 시큐리티 필터를 타지 않도록 WebSecurityCustomizer 에 설정해야 한다. 왜냐하면 시큐리티 경로상 permitAll() 을 하여도 필터는 타게 되는데 JWT 상에는 요청 검사를 필터에서 하기 때문이다.

문제는 필터까지 아예 안태워버리는 경우, 보안상 사용자의 권한에 맞게 리소스 접근해야 하는 상황에서는 시큐리티 인증 경로 검사를 태워야 하기 때문에 아예 막을 수는 없는 것이다.

실제로 프로젝트 업무 담당하게 되면서 보안 상의 이유로 사용자의 권한에 맞는 리소스와 전체 공개 리소스의 경로를 나누고 시큐리티 경로 검사를 태웠기 때문에 모든 요청에 대해 검사를 하더라도 정적 리소스 경로 중 전체 공개 경로는 검사하지 않아야 하는 문제가 발생했다.

따라서, 모든 요청에 대해 검사를 하되 전체 공개 정적 리소스 같은 예외적인 케이스를 필터에서 처리해야 했다.

package com.example.demo.config;

import com.example.demo.service.CustomUserDetailsService;
import com.example.demo.util.JwtTokenUtil;
import com.example.demo.util.RefreshTokenStore;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenUtil jwtTokenUtil;
    private final RefreshTokenStore refreshTokenStore;
    private final CustomUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtTokenUtil jwtTokenUtil,
                                   RefreshTokenStore refreshTokenStore,
                                   CustomUserDetailsService userDetailsService) {
        this.jwtTokenUtil = jwtTokenUtil;
        this.refreshTokenStore = refreshTokenStore;
        this.userDetailsService = userDetailsService;
    }

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

        // JWT 검사를 하지 않을 경로 예외 처리
        String requestURI = request.getRequestURI();
        if (isPublicPath(requestURI)) {
            filterChain.doFilter(request, response); // 인증 로직 생략
            return;
        }

        String header = request.getHeader("Authorization");

        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring(7);

            // Step 1: Access Token 유효한 경우
            if (jwtTokenUtil.validateToken(token)) {
                String username = jwtTokenUtil.getUsernameFromToken(token);
                authenticateUser(username, request);
            } else {
                // Step 2: Refresh Token 검사
                String refreshToken = request.getHeader("X-Refresh-Token");

                if (refreshToken != null) {
                    try {
                        String username = jwtTokenUtil.getUsernameFromToken(token); // expired token에서도 claim 추출 가능
                        String stored = refreshTokenStore.getToken(username);

                        if (stored != null && stored.equals(refreshToken) && jwtTokenUtil.validateToken(refreshToken)) {
                            // 새 Access Token 발급
                            String newAccessToken = jwtTokenUtil.generateToken(username);
                            response.setHeader("X-New-Access-Token", newAccessToken);

                            authenticateUser(username, request);
                        } else {
                            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid refresh token");
                            return;
                        }
                    } catch (Exception e) {
                        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid refresh token");
                        return;
                    }
                } else {
                    response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Token expired and no refresh token provided");
                    return;
                }
            }
        }

        filterChain.doFilter(request, response);
    }

    private void authenticateUser(String username, HttpServletRequest request) {
        var userDetails = userDetailsService.loadUserByUsername(username);
        var auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(auth);
    }

    // 필터를 생략할 공개 경로 목록
    private boolean isPublicPath(String uri) {
        return uri.startsWith("/api/public/") ||
                uri.equals("/login") ||
                uri.equals("/signup") ||
                uri.equals("/favicon.ico");
    }
}

JWT 방식에서의 시큐리티 컨텍스트?

처음에 나는 JWT를 사용하더라도 시큐리티 컨텍스트에 인증 정보를 저장하고, 이를 요청에 상관없이 자유롭게 사용하려고 시도했다. 그래서 여러번 구현 후 시큐리티 컨텍스트 인증 정보를 가져오려고 했지만 번번히 에러가 발생했다.

그래서 찾아보니 다음과 같은 사실을 알게 되었다.

JWT 방식을 사용하게 되면 다음과 같이 세션 생성을 막게 된다. 문제는 세션 생성을 막기 때문에 인증 과정에서 시큐리티 컨텍스트에 명시적으로 인증이 저장된 요청 간에만 유지되고 날라간다는 것이다.

그래서 시큐리티 인증 정보에 대한 명시적인 저장 및 사용은 의미가 없다는 것을 알았고 세션 생성을 막기 위해 시큐리티 설정 시에는 다음과 같이 설정한다.

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

로그인 과정에서의 토큰 세팅

로그인을 할 때는 다음과 같이 토큰을 세팅해준다. 기본적으로 토큰들을 리턴하여 사용해도 되지만 불필요하게 노출될 일이 없도록 쿠키를 사용하여 제어한다.

제일 중요한 것은 요청 구간에서만 사용할 수 있게 (프론트에서 건드리지 못하게) HttpOnly 설정을 해줘야 한다는 것이다.

@PostMapping("/login")
public Map<String, String> login(@RequestParam String username, HttpServletResponse response) {
    // ✅ 액세스 토큰 & 리프레시 토큰 생성
    String accessToken = jwtTokenUtil.generateToken(username);
    String refreshToken = jwtTokenUtil.generateRefreshToken(username);

    // ✅ 액세스 토큰을 HttpOnly 쿠키에 저장
    Cookie accessCookie = new Cookie("access_token", accessToken);
    accessCookie.setHttpOnly(true);
    accessCookie.setSecure(true);
    accessCookie.setPath("/");
    accessCookie.setMaxAge(60 * 30); // 30분

    // ✅ 리프레시 토큰을 HttpOnly 쿠키에 저장
    Cookie refreshCookie = new Cookie("refresh_token", refreshToken);
    refreshCookie.setHttpOnly(true);
    refreshCookie.setSecure(true);
    refreshCookie.setPath("/");
    refreshCookie.setMaxAge(60 * 60 * 24 * 7); // 7일

    response.addCookie(accessCookie);
    response.addCookie(refreshCookie);

    return Map.of(
            "accessToken", accessToken,
            "refreshToken", refreshToken
    );
}

그래서 기본적으로 이렇게 세팅을 하고 JWT 로그인 구성을 시작했다. 그 외에는 프로젝트 진행 상황에서 업무적으로 필요한 로직이나 기타 구현들의 살을 붙여나가며 진행했다.

요약하자면 다음과 같다.

  1. JWT에서는 필터에서 모든 요청을 검사하기 때문에 여기에 검증 로직이 전부 들어가며, 예외처리 또한 필요하다.
  2. JWT 검증 상황에서는 시큐리티 컨텍스트에 인증 정보가 유지되지 않는다.
  3. 토큰을 쿠키에 세팅할때는 HttpOnly 설정을 꼭! 걸어주어야 한다.

예제 소스는 여기서 확인 가능합니다!

https://github.com/Chiptune93/springboot.java.example/tree/feature/spring-security/jwt-basic


© 2024. Chiptune93 All rights reserved.