말썽쟁이 Spring Security 정리

위승현·2025년 1월 11일
0

Spring

목록 보기
12/12

1. 난 화이트 리스트 처리했는데??

Spring Security, JWT 토큰을 사용하여 다 구현했다고 생각했는데
하나의 문제가 발생했다.

예를들어 현재 우리 프로젝트의 WebConfig 클래스에서
Spring SecurityFilterChain 을 구현해놓은 상태이다.

@Configuration
@EnableWebSecurity // SecurityFilterChain 빈 설정을 위해 필요.
@RequiredArgsConstructor
public class WebConfig {

    private final JwtAuthFilter jwtAuthFilter;

    private final AuthenticationProvider authenticationProvider;

    private final AuthenticationEntryPoint authEntryPoint;

    private final AccessDeniedHandler accessDeniedHandler;

	@Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(AbstractHttpConfigurer::disable)
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(WHITE_LIST)
                .permitAll()
                .requestMatchers(HttpMethod.GET, "/products/*").permitAll()
//..이하 생략
				.addFilterAfter(jwtAuthFilter, ExceptionTranslationFilter.class);
        return http.build();
    }
}

이 SecurityFilterChain 에서 따로구현된 jwtAuthFilter 를 추가해주는 방식으로 구현했다.
하지만 이 방식에는 하나의 문제가 있었다.

분명 WHITE_LIST 로 구현해둔 경로로 요청을보내도 등록해둔 jwtAuthFilter 가 동작하여
토큰이 없다는 에러 메시지가 출력되는 것이었다.

분명 SecurityFilterChain 에 화이트 리스트로 필터에 걸리지 않을 경로들을
지정해주었음에도 불구하고 왜 jwtAuthFilter 에서 authenticate() 가 진행될까??


1. SecurityFilterChain과 화이트리스트 동작

Spring Security는 요청이 들어올 때 필터 체인을 구성한다. SecurityFilterChain
이 필터 체인은 다음과 같은 특징을 가진다.

  1. 등록된 모든 필터는 요청을 처리하려고 시도

  2. 화이트리스트 설정은 인증/인가를 수행하는 Spring Security의 인증 관련 필터에만 적용

  3. 화이트리스트에 등록된 요청이라도 필터 체인에 있는 사용자 정의 필터(Custom Filter)는 여전히 실행

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/users/login", "/users/signup").permitAll()
    .anyRequest().authenticated()
);
  • 이 설정은 Spring Security의 인증/인가 처리를 해당 경로에서 제외한다.
  • 그러나 중요한 점은 필터 체인의 다른 모든 필터가 여전히 요청을 처리한다는 점이다.

2. JwtAuthFilter의 역할

JwtAuthFilter는 모든 요청을 처리하도록 설계된 OncePerRequestFilter를 상속받는다
따라서 모든 요청에서 실행된다.
화이트리스트 설정은 Spring Security의 인증/인가를 우회시키지만,
JwtAuthFilter는 우리가 만든 커스텀 필터이기 때문에
SecurityFilterChain의 경로 설정과는 별개로 실행
되는 것이다..

즉, JwtAuthFilterSpring SecurityFilterChain 내에서 설정한 경로와 상관없이 항상 실행되며, 내부적으로 별도로 화이트리스트를 확인하지 않는 한 모든 요청에서 실행된다.


3. 왜 SecurityFilterChain 설정이 JwtAuthFilter에 영향을 주지 않을까?

이것은 Spring Security의 필터 체인 구조 때문이다.
JwtAuthFilterSpring Security의 인증/인가 관련 필터가 아니기 때문에, SecurityFilterChain의 화이트리스트 설정이 적용되지 않는다.

  • SecurityFilterChain의 경로 설정은
    ExceptionTranslationFilter 또는 AuthorizationFilter 같은
    Spring Security의 기본 필터에만 영향을 준다.

  • 반면, JwtAuthFilter는 사용자 정의 필터이므로
    모든 요청에서 실행되도록 설계된다.


4. 문제 해결 방법

JwtAuthFilter 내부에서 화이트리스트 경로를 확인

화이트리스트 경로에 대해 필터를 건너뛰도록 JwtAuthFilter에서도 경로 설정을 해주어야한다.

@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;
    private final UserDetailsService userDetailsService;

    // 화이트리스트 경로 정의
    private static final List<String> WHITE_LIST = List.of("/users/login", "/users/signup");

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String requestUri = request.getRequestURI();

        // 화이트리스트 경로라면 필터링을 건너뜀
        if (WHITE_LIST.contains(requestUri)) {
            filterChain.doFilter(request, response);
            return;
        }

        // 인증 처리
        this.authenticate(request);
        filterChain.doFilter(request, response);
    }

   //.. 이하 생략
}

5. 결론

  • JwtAuthFilter는 SecurityFilterChain의 경로 설정과는 무관하게 모든 요청에서 실행
  • 따라서, JwtAuthFilter 내부에서 화이트리스트 경로를 직접 처리해야함.

이렇게 완벽하게 화이트 리스트를 처리하려면 직접 만든 JwtAuthFilter, SecurityFilterChain
모두에서 화이트 리스트를 적용시켜야 한다는 사실을 알았다.

그런데 너무 중복되지않나??

경로를 한 곳에서 관리하여 처리할 수는 없을까?? 물론 있다!



2. SecurityProperties를 사용해보자!!

1번에서 알아낸 방법을 적용하면 WHITE_LIST 를 중복으로 적용하는 모습이 발생했다.
따라서 application.yml 설정파일에 화이트 리스트를 적용할 url 들을 추가하여
사용하는 방법을 선택했다.

  • application.yml
security: # SecurityProperties의 prefix와 일치
  white-list: # SecurityProperties의 whiteList 필드와 매핑
    - "/users/login"
    - "/users/signup"
    - "/toss/fail"
    - "/toss/success"
    - "/toss/confirm"
  seller-auth-list:
    - "/users/sellers/**"
    - "/products"
    - "/products/**"
  method-specific-patterns:
    GET:
      - "/products/*"
      - "/products"

이렇게 화이트 리스트, 판매자 권한 경로, GET 메서드로 들어올 때만 인증을 하지 않는
경로들을 추가했다.


  • SecurityProperties
// application.yml의 security: 아래 설정들을 이 클래스와 매핑
@ConfigurationProperties(prefix = "security")
@Component
@Getter
@Setter
public class SecurityProperties {

    private List<String> whiteList = new ArrayList<>();  // security.white-list와 매핑
    private List<String> sellerAuthList = new ArrayList<>(); // security.seller-auth-list와 매핑

    // security.method-specific-patterns와 매핑
    private Map<HttpMethod, List<String>> methodSpecificPatterns = new HashMap<>();
}

그 후에 yml 파일의 내부의 security: 설정들을 매핑하는 SecurityProperties 클래스를
생성하였다.

@ConfigurationProperties(prefix = "security") 어노테이션을 사용하여
yml 파일의 security: 와 매핑한다는것을 명시해준다.

또한 Bean 으로 등록하여 사용하는 곳에서 의존성 주입을 받을 수 있도록 설정한다.

후에 설정 내부에 있는 필드들과 변수를 매핑하여 각각 리스트로 만든다.
마지막은 Method 에 따라 달라지기 때문에 Map으로 설정했다.


  • WebConfig
@Configuration
@EnableWebSecurity // SecurityFilterChain 빈 설정을 위해 필요.
@RequiredArgsConstructor
public class WebConfig {

    private final JwtAuthFilter jwtAuthFilter;

    private final AuthenticationProvider authenticationProvider;

    private final AuthenticationEntryPoint authEntryPoint;

    private final AccessDeniedHandler accessDeniedHandler;
    
    private final SecurityProperties securityProperties;
    
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(Customizer.withDefaults())
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(securityProperties.getWhiteList().toArray(new String[0]))
                .permitAll()
                .requestMatchers(HttpMethod.GET, "/products/*").permitAll()
                .requestMatchers(HttpMethod.GET, "/products").permitAll()
                .requestMatchers(HttpMethod.POST, "/persona/*").permitAll()
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
                .dispatcherTypeMatchers(DispatcherType.FORWARD, DispatcherType.INCLUDE,
                    DispatcherType.ERROR).permitAll()
                .requestMatchers(securityProperties.getSellerAuthList().toArray(new String[0]))
                .hasRole("SELLER")
                .anyRequest().authenticated()
            )

이렇게 설정해둔 SecurityProperties 를 주입받아서 각각 리스트를 추출하여
사용하는 모습을 볼 수 있다.

private Map<HttpMethod, List<String>> methodSpecificPatterns = new HashMap<>();

이거는 왜 안쓰냐고 할 수 있는데 FilterChain 에서는 메서드와 url을 지정할 수가 있지만

1번 과정에서 알았듯이 JwtAuthFilter 에서도 화이트 리스트를 적용해줘야하기 때문에
Map 을 만든 것이다. 사용하는 것을 보도록 하자.


  • JwtAuthFilter
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtProvider jwtProvider;

    private final UserDetailsService userDetailsService;

    private final SecurityProperties securityProperties;

    private final AntPathMatcher pathMatcher = new AntPathMatcher();

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

        // 화이트 리스트를 판단하기 위한 uri 와 method
        String requestUri = request.getRequestURI();
        String method = request.getMethod();

        // SecurityProperties에서 설정값들을 가져와서 사용
        if (securityProperties.getWhiteList().stream()
            .anyMatch(pattern -> pathMatcher.match(pattern, requestUri))) {
            filterChain.doFilter(request, response);
            return;
        }

        // HTTP Method 특정 패턴 체크, (GET /products/search 를 필터링 하지 않기위해 구현)
        Map<HttpMethod, List<String>> methodPatterns = securityProperties.getMethodSpecificPatterns();
        if (methodPatterns.containsKey(HttpMethod.valueOf(method))) {
            if (methodPatterns.get(HttpMethod.valueOf(method)).stream()
                .anyMatch(pattern -> pathMatcher.match(pattern, requestUri))) {
                filterChain.doFilter(request, response);
                return;
            }
        }

        this.authenticate(request);
        filterChain.doFilter(request, response);
    }
    
    //이하 생략...

JwtAuthFilter는 FilterChain 에서 처럼 메서드까지 지정할 수는 없기 때문에
매핑한 Map 을 가져와 어떤 메서드의 어떤 경로들이 화이트리스트에 포함되는지
확인하는 과정을 거쳐야한다.

이 때 AntPathMatcher 를 사용하여 매칭 여부를 판단한다.

AntPathMatcher 는 또 뭐야?

AntPathMatcher는 Spring Framework에서 패턴 매칭을 위해 제공되는 유틸리티 클래스다.
경로(URL)나 경로 템플릿과 HTTP 요청의 URI를 비교하여 매칭 여부를 판단하는 데 사용된다.

주로 지금처럼 화이트리스트(허용된 경로)를 검사하거나
특정 패턴에 따라 요청을 필터링
하는 작업에서 유용하게 사용된다.


AntPathMatcher의 주요 기능

  1. Ant 스타일 패턴 매칭:

    • *: 경로의 특정 부분에 대해 와일드카드 매칭을 수행.
      • 예: /products/*/products/123, /products/abc 매칭.
    • **: 하위 경로 전체를 매칭.
      • 예: /products/**/products/123/details, /products/abc/edit 매칭.
    • ?: 단일 문자와 매칭.
      • 예: /products/??/products/12, /products/ab 매칭.
  2. 정규식 지원:

    • Ant 스타일 이외에도 복잡한 매칭 조건이 필요한 경우 정규식도 사용이 가능하다.

doFilterInternal에서의 동작

  1. 화이트리스트 검사:

    • securityProperties.getWhiteList()에서 허용된 경로(화이트리스트)를 가져온다.

    • 각 요청 URI와 화이트리스트의 경로 패턴을
      AntPathMatcher.match()로 비교하여 요청이 화이트리스트에 포함되는지 확인.

    • 화이트리스트에 매칭되면 필터링을 건너뛰고 바로 다음 필터로 요청을 전달.

    if (securityProperties.getWhiteList().stream()
        .anyMatch(pattern -> pathMatcher.match(pattern, requestUri))) {
        filterChain.doFilter(request, response);
        return;
    }
  2. HTTP 메서드별 특정 경로 검사:

    • securityProperties.getMethodSpecificPatterns()에서
      HTTP 메서드별로 설정된 패턴 리스트를 가져온다.

    • 요청의 HTTP 메서드가 해당 리스트에 포함되어 있는지 확인하고,
      매칭되면 필터링을 건너뛰고 바로 다음 필터로 요청을 전달.

    if (methodPatterns.containsKey(HttpMethod.valueOf(method))) {
        if (methodPatterns.get(HttpMethod.valueOf(method)).stream()
            .anyMatch(pattern -> pathMatcher.match(pattern, requestUri))) {
            filterChain.doFilter(request, response);
            return;
        }
    }
  3. JWT 인증 처리:

    • 요청이 화이트리스트나 특정 HTTP 메서드별 패턴에 매칭되지 않는 경우, authenticate(request)를 호출하여 JWT 검증 및 인증 처리를 진행

    • 이후 요청을 다음 필터로 전달.

    this.authenticate(request);
    filterChain.doFilter(request, response);

AntPathMatcher를 사용하는 이유

  1. 유연한 경로 매칭:

    • Spring의 AntPathMatcher는 Ant 스타일 패턴 매칭을 지원하므로,
      특정 경로를 유연하게 정의하고 비교할 수 있도록 도와준다.

    • 예: /users/**와 같은 경로는 /users/login, /users/signup
      모든 하위 경로를 포함

  2. 화이트리스트 및 특정 경로 필터링 간소화:

    • 화이트리스트 및 HTTP 메서드별 특정 경로를
      쉽게 매칭하고 처리할 수 있어 코드 가독성을 높일 수 있다.
  3. Spring Security와의 자연스러운 통합:

    • Spring SecurityFilterChain에서도 경로 매칭에 Ant 스타일을 기본적으로 지원하므로,
      동일한 방식으로 필터를 적용할 수 있다.

예시 동작 흐름

  1. 화이트리스트 경로 확인:

    • 예: 요청 URI가 /users/login일 때,
      /users/login이 화이트리스트에 포함되어 있으면 매칭.
    • 매칭된 경우 filterChain.doFilter()를 호출하고 필터링을 건너뜀.
  2. HTTP 메서드와 경로 확인:

    • 예: 요청이 GET /products/search일 때, 특정 메서드(GET)와 경로(/products/search)가 매칭되면 필터링을 건너뜀.
  3. JWT 인증 처리:

    • 예: 요청 URI가 /products/123인 경우, 화이트리스트나 특정 HTTP 메서드 경로와 매칭되지 않으면 authenticate() 메서드로 JWT 검증 수행.

결론

AntPathMatcher는 화이트리스트 경로와 HTTP 메서드별 특정 경로를 유연하게 매칭하여,
필터 체인의 로직을 간소화하고 가독성을 높이는 데 유용하다.
doFilterInternal에서는 이를 사용해 불필요한 필터링 작업을 건너뛰고,
JWT 인증 처리를 효율적으로 수행
할 수 있도록 구현했다.

이렇게 구현을 마치고 나면 정상적으로 화이트 리스트가 적용되는 모습을 볼 수 있다.



3. CORS 설정??

프론트 단을 간단하게 구현해보고 회원가입 요청을 서버로 보내는 과정에서
CORS policy 오류가 발생한 모습을 볼 수 있었다.

왜지??

	@Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.cors(AbstractHttpConfigurer::disable)
            .csrf(AbstractHttpConfigurer::disable)

WebConfig 에 있는 SecurityFilterChain 에서 disable 해서 비활성화 시키지 않았나?
근데 왜 정책에 걸리는걸까??

CORS 정책 문제는 주로 브라우저와 서버 간에
교차 출처 요청(Cross-Origin Request)이 올바르게 처리되지 않을 때 발생한다.


1. CORS와 Spring Security

Spring Security에서 CORS는 일반적으로
HttpSecurity.cors()활성화해야 제대로 작동한다.

cors(AbstractHttpConfigurer::disable)로 설정하면
CORS 처리가 비활성화되어 CORS 정책 문제가 발생할 수 있다.


2. 현재 구조에서 CORS 비활성화 문제

현재 설정에서 CORS를 비활성화했기 때문에 브라우저가
OPTIONS 요청(preflight 요청)을 보내도 적절한 응답을 받을 수 없다.


3. CORS 문제 해결 방법

따라서 CORS 를 활성화 해주고 설정을 해주는 과정이 필요하다.

3.1 Spring Security에서 CORS 활성화

cors(AbstractHttpConfigurer::disable)를 제거하고, HttpSecurity.cors()를 활성화한 뒤, CorsConfigurationSource 빈을 정의하여 CORS 정책을 설정한다.

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.addAllowedOriginPattern("*"); // 모든 출처 허용
    configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용
    configuration.addAllowedHeader("*"); // 모든 헤더 허용
    configuration.addExposedHeader("Authorization"); // 클라이언트가 접근 가능한 헤더
    configuration.setAllowCredentials(true); // 인증 정보 포함 허용

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", configuration);
    return source;
}

WebConfig에서 해당 cors()를 활성화한다.:

http.cors(Customizer.withDefaults())
    .csrf(AbstractHttpConfigurer::disable)
    .authorizeHttpRequests(auth -> auth
        .requestMatchers(securityProperties.getWhiteList().toArray(new String[0])).permitAll()
        .anyRequest().authenticated()
    );

5. 결론

  • CORS 문제는 HttpSecurity.cors()를 활성화하고,
    CorsConfigurationSource 빈을 올바르게 설정하면 해결된다.

  • 지금은 로컬에서만 실행하기 때문에 보안 같은 것을 신경쓰지 않고 적용했는데
    나중에 배포를 하는 경우라면 보안 쪽에 더 신경을 쓸 필요가 있어보인다.



4. Refresh Token 구현 과정

아무래도 지금 프론트를 구현하고 있기 때문에 액세스 토큰이 만료되었을 때
다시 로그인하는과정에서 불편함이 느껴졌다.

해결할 방법을 찾아보다 Refresh Token 이라는 것을 서버에서 구현하여 보내주면
프론트에서는 해당 Refresh Token 을 사용하여 자동적으로 액세스 토큰을 요청할 수 있다.


Refresh Token?

리프레시 토큰(Refresh Token)은 액세스 토큰(Access Token)과 함께 사용되어,
액세스 토큰이 만료된 경우 재발급을 처리할 수 있도록 설계된 토큰이다.
리프레시 토큰은 일반적으로 더 긴 유효 기간을 가지며, 서버에서 안전하게 관리된다.

1. Refresh Token의 역할

  1. 액세스 토큰 만료 시 클라이언트가 리프레시 토큰을 사용해 새로운 액세스 토큰을 요청.
  2. 리프레시 토큰은 일반적으로 데이터베이스나 메모리 캐시(Redis)에 저장하여 관리.

2. 액세스 토큰과 리프레시 토큰 비교

항목액세스 토큰리프레시 토큰
사용 목적인증 및 권한 정보 전달새로운 액세스 토큰 발급 요청
유효 기간짧음 (예: 15~30분)김 (예: 7일, 30일 등)
클라이언트 저장로컬 스토리지, 쿠키보통 쿠키에 저장 (HTTP-Only Secure)
서버 저장 여부보통 저장하지 않음데이터베이스나 Redis에 저장

3. 보안 고려 사항

1) 리프레시 토큰 저장 위치:

  • 리프레시 토큰은 HTTP-Only Secure 쿠키에 저장하는 것이 안전하다.
  • 브라우저에서 스크립트를 통해 접근할 수 없으므로 XSS 공격을 방지할 수 있다.

2) 토큰 블랙리스트:

  • 만약 리프레시 토큰이 탈취되었을 경우를 대비하여, 블랙리스트를 관리할 수 있다.
  • Redis와 같은 메모리 캐시를 사용하여 토큰 상태를 관리하는 것이 유용하다.

3) 리프레시 토큰 갱신:

  • 리프레시 토큰이 사용될 때마다 새로 갱신하는 방식도 있지만
    로그인할 때 리프레시 토큰을 발급받도록 구현할 것이다.

실제로 구현해보자

이제 리프레시 토큰이 무엇인지는 알게되었으니 구현하는 과정의 흐름을 알아보자.
Redis 를 사용하면 좋다고 하지만 일단 첫 구현이니 DB 에 저장하는 방식으로 진행해보고자 한다.


구현 흐름

1) 로그인 시 액세스 토큰과 리프레시 토큰 발급

  • 사용자가 이메일과 비밀번호로 로그인 요청.

  • 서버:

    1. 사용자 인증(예: 이메일과 비밀번호 확인).

    2. 액세스 토큰리프레시 토큰 생성.

    3. 리프레시 토큰을 DB에 저장하고, 클라이언트로 전달.


액세스 토큰

  • 내용:

    • 사용자 이메일, 권한(Role), 만료 시간(exp) 등 인증에 필요한 정보 포함.
  • 유효 기간:

    • 일반적으로 짧게 설정(15~30분).

    리프레시 토큰

  • 내용:

    • 일반적으로 sub(사용자 식별자)와 만료일만 포함.
    • 민감한 정보는 포함하지 않음.
  • 유효 기간:

    • 일반적으로 길게 설정(7일~30일).
  • DB 저장:

    • 리프레시 토큰과 만료일을 DB에 저장.
    • 탈취된 토큰을 무효화하거나 관리할 수 있도록 하기 위함.

2) 리프레시 토큰 저장

  • 서버는 리프레시 토큰을 DB에 저장.

  • DB 구조:

    @Entity
    public class RefreshToken {
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private Long userId; // 사용자 ID
        private String refreshToken; // 리프레시 토큰 값
        private LocalDateTime expiresAt; // 만료 시간
    }

이유

  • 보안 강화:
    • 리프레시 토큰이 탈취되더라도, 서버에서 강제로 만료 처리 가능
  • 상태 관리:
    • 로그아웃, 비밀번호 변경 등 이벤트에서 리프레시 토큰을 무효화

3) 액세스 토큰 만료 시 리프레시 토큰 사용

  1. 클라이언트가 보호된 리소스에 접근했지만, 액세스 토큰 만료로 실패.

  2. 클라이언트는 리프레시 토큰을 사용해 새로운 액세스 토큰 발급 요청:

    POST /refresh
    Authorization: Bearer <refresh_token>

4) 리프레시 토큰 검증

검증 과정

  1. JWT 자체 검증:

    • 리프레시 토큰의 유효성 확인:
      • 위변조 여부(서명 검증).
      • 만료 시간(exp) 확인.
  2. DB와 일치 여부 확인:

    • 토큰이 DB에 존재하고, 만료 시간이 유효한지 확인.

5) 액세스 토큰 재발급

  1. 리프레시 토큰 검증 성공 시 새로운 액세스 토큰 생성

  2. 새로운 액세스 토큰을 클라이언트로 반환

    HTTP/1.1 200 OK
    Authorization: Bearer <new_access_token>

추가 고려 사항

  1. 리프레시 토큰 갱신:

    • 사용된 리프레시 토큰을 갱신하여 새로운 리프레시 토큰을 반환
  2. 보안 강화:

    • 리프레시 토큰은 HTTP-Only Secure 쿠키에 저장하여 클라이언트 스크립트에서 접근 불가
  3. 로그아웃 처리:

    • 로그아웃 시 리프레시 토큰을 DB에서 삭제하여 무효화

이러한 흐름이라고 이해를 했고 바로 개발에 들어갔다.
여기부터는 코드가 어떤 동작을 하는지 간단히 서술할 예정이니 빠르게 넘어가도 좋다.


로그인 기능 구현

  • UserController.java
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
    private final RefreshTokenService refreshTokenService;

    /**
     * 사용자 로그인
     *
     * @param requestDto 로그인 관련 정보를 담고있는 요청 DTO
     * @return 정상 처리시 헤더에 액세스토큰, 쿠키에 리프레시 토큰을 반환
     */
    @PostMapping("/login")
    public ResponseEntity<Void> login(
        @Valid @RequestBody UserLoginRequestDto requestDto
    ) {
        TokenResponse tokenResponse = userService.loginTokenGenerate(requestDto);

        ResponseCookie refreshCookie = ResponseCookie.from("refreshToken",
                tokenResponse.getRefreshToken())
            .httpOnly(true)
            .path("/")
            .secure(false)
            .maxAge(7 * 24 * 60 * 60)
            .build();

        return ResponseEntity.ok()
            .header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
            .header(HttpHeaders.AUTHORIZATION, "Bearer " + tokenResponse.getAccessToken())
            .build();
    }
}

우선 UserService 에서 로그인 로직을 호출해서 액세스 토큰, 리프레시 토큰 값을 받아온다

리프레시 토큰값을 통해 ResponseCookie 객체를 생성하고 헤더의 쿠키에 설정,
액세스 토큰값을 AUTHORIZATION 헤더에 할당한다.

  • UserService
public TokenResponse loginTokenGenerate(UserLoginRequestDto requestDto) {

        User user = findByEmail(requestDto.getEmail());

        if (user.getStatus() == Status.WITHDRAW) {
            throw new CustomResponseStatusException(ErrorCode.FORBIDDEN_DELETED_USER_LOGIN);
        }

        // 이 과정에서 Provider 가 인증 처리를 진행 (사용자 정보 조회, 비밀번호 검증)
        Authentication authentication = authenticationManager.authenticate(
            new UsernamePasswordAuthenticationToken(requestDto.getEmail(),
                requestDto.getPassword()));

        // 인증 객체를 SecurityContext에 저장
        SecurityContextHolder.getContext().setAuthentication(authentication);

        // JWT 생성 후 반환
        return jwtProvider.generateToken(authentication);
        // access, refresh 토큰 생성 후 반환
        String accessToken = jwtProvider.generateToken(authentication);
        String refreshToken = jwtProvider.generateRefreshToken(authentication);
        refreshTokenService.saveRefreshToken(user.getId(), refreshToken);
        return new TokenResponse(accessToken, refreshToken);
    }

서비스 로직을 보면 JwtProvider의 generateRefreshToken 메서드를 이용해서
리프레시 토큰을 생성하고 그 값을 DB 에 저장한 후에 DTO 에 액세스 토큰과 함께 담아서
반환해주는 것을 볼 수 있다.

  • JwtProvider
public String generateRefreshToken(Authentication authentication) {
        String email = authentication.getName(); // Authentication에서 사용자 이메일 가져오기
        return Jwts.builder()
            .subject(email)
            .issuedAt(new Date())
            .expiration(new Date(System.currentTimeMillis() + this.refreshExpiryMillis))
            .signWith(Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)), Jwts.SIG.HS256)
            .compact();
}

리프레시 토큰은 액세스 토큰과 달리 별다른 정보 없이 생성하는 것이지만
혹시 나중에 필요한 경우가 있지 않을까 싶어 email 정보만 포함하고
refreshExpiryMills 는 설정 파일에 추가된 것을 가져와 사용했다.
현재 시간 + 7일의 토큰이 결과적으로 생성된다.


로그아웃 기능 구현

  • UserController
@PostMapping("/logout")
public ResponseEntity<Void> logout(
    HttpServletRequest request,
    HttpServletResponse response,
    Authentication authentication,
    @CookieValue(value = "refreshToken", required = false) String refreshToken) {

    if (authentication != null && authentication.isAuthenticated()) {
        // SecurityContext 로그아웃
        new SecurityContextLogoutHandler().logout(request, response, authentication);

        // 리프레시 토큰 삭제
        if (refreshToken != null) {
            refreshTokenService.deleteRefreshToken(refreshToken);
        }

        // 쿠키 만료 처리
        ResponseCookie expiredCookie = ResponseCookie.from("refreshToken", "")
            .httpOnly(true)
            .path("/")
            .maxAge(0) // 만료 처리
            .build();

        return ResponseEntity.ok()
            .header(HttpHeaders.SET_COOKIE, expiredCookie.toString())
            .build();
    }

    throw new UsernameNotFoundException("로그인이 먼저 필요합니다.");
}

물론 로그아웃 했을 때 처리도 있어야겠지?
SecurityContext 의 로그아웃 기능을 호출하고, DB 의 리프레시 토큰을 삭제한 후
쿠키를 만료처리하여 반환해주는 과정을 거쳤다
액세스 토큰은 어짜피 프론트 단에서 로그아웃했을 때 로컬 스토리지를 비우는 과정을
거치기 때문에 따로 하지 않았지만 나중에 추가하는 과정이 필요해보인다.


리프레시 토큰으로 액세스 토큰 갱신 요청

  • RefreshTokenController
@RestController
@RequiredArgsConstructor
public class RefreshTokenController {
    private final RefreshTokenService refreshTokenService;
    @PostMapping("/refresh")
    public ResponseEntity<Void> refresh(@CookieValue("refreshToken") String refreshToken) {
        try {
            // 리프레시 토큰으로 새로운 액세스 토큰 발급
            String newAccessToken = refreshTokenService.generateAccessTokenFromRefreshToken(
                refreshToken);
            return ResponseEntity.ok()
                .header(HttpHeaders.AUTHORIZATION, "Bearer " + newAccessToken)
                .build();
        } catch (CustomResponseStatusException e) {
            // 리프레시 토큰이 만료되었거나 유효하지 않은 경우
            if (e.getErrorCode() == ErrorCode.BAD_REQUEST_TOKEN) {
                // 만료된 리프레시 토큰 삭제
                refreshTokenService.deleteRefreshToken(refreshToken);
                // 쿠키 삭제를 위해 만료시간을 0으로 설정
                ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", "")
                    .httpOnly(true)
                    .path("/")
                    .maxAge(0)
                    .build();
                return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .header(HttpHeaders.SET_COOKIE, refreshCookie.toString())
                    .build();
            }
            throw e;
        }
    }
}
  • RefreshTokenService

public String generateAccessTokenFromRefreshToken(String refreshToken) {
	validRefreshToken(refreshToken);
    
    String email = jwtProvider.getUserName(refreshToken);
    
    UserDetails userdetails = userDetailsService.loadByUserName(email);
    
    Authentication authentication = new UsernamePasswordAuthenticationToken(
    	userdetails, null, userDetails.getAuthorities());
        
    return jwtProvider.generateToken(authentication);
}

public void validRefreshToken(String refreshToken){
	if(!jwtProvider.validToken(refreshToken) {
    	throw new CustomResponseStatusException(ErrorCode.BAD_REQUEST_TOKEN);
    }
    
    RefreshToken token = refreshTokenRepository.findByRefreshToken(refreshToken)
    	.orElseThrow(() -> new CustomResponseStatusException(ErrorCode.BAD_REQUEST_TOKEN));
        
    if(token.getExpiredAt().isBefore(LocalDateTime.now()) {
    	refreshTokenRepository.delete(token);
        throw new CustomResponseStatusException(ErrorCode.BAD_REQUEST_TOKEN);
    }
}

프론트 단에서 /refresh 로 액세스 토큰 갱신을 요청했을 때
서비스단에서 새로운 액세스 토큰을 발급해주는데
이 과정에서 검증이 실패하면 CustomResponseStatusException 이 발생할 수 있다.

따라서 try catch 문으로 해당 에러를 감지하면 리프레시 토큰이 유효하지 않다고
판단하여 DB의 리프레시 토큰을 삭제하고 쿠키를 삭제하여 반환하는 과정이다.


간단하지만 놓치면 안되는 부분

  • CORS 설정
@Bean
    CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        //.. 생략
        configuration.addExposedHeader("Set-Cookie");
        configuration.setAllowCredentials(true); // 인증 정보 포함 허용

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }

CORS 설정에서 프론트에서 쿠키 정보를 넘겨줄 수 있게 Header 를 허용하고
Credential 인증 정보 포함을 허용으로 설정해주어야한다.


리프레시 토큰을 DB에 저장해도 될까?

리프레시 토큰을 DB에 저장하는 방식은 구현이 비교적 간단하여 편리하다.
하지만 편리한만큼 단점도 있는 법!
어떤 문제가 있는지 알아보고 대안책을 살펴보자

1. 성능 문제

1) DB I/O 부하

  • 사용자가 많아지면 리프레시 토큰을 검증하거나 저장하기 위해 DB 접근이 빈번해짐

  • 특히, 고트래픽 애플리케이션에서는 DB I/O 부하가 시스템의 성능 병목을 초래할 수 있음

2) 대규모 트래픽 처리에 비효율적

  • 리프레시 토큰 검증 및 삭제가 빈번하게 일어나는 경우,
    DB의 Read/Write 작업이 과도해질 수 있음.

  • DB는 기본적으로 디스크 기반이므로 메모리 기반 저장소에 비해 처리 속도가 느림

현재 구현한 방식만 보아도 로그인을 할 때 리프레시 토큰 생성 -> 저장
로그아웃을 할 때 저장되어있는 리프레시 토큰을 삭제 하는 과정이 일어나기 때문에
사용자가 많아지면 성능의 문제가 생기는 것은 당연할 것으로 보인다


2. 복잡성 증가

1) 동기화 문제

  • 만약에 분산 환경에서 여러 DB 인스턴스를 사용하는 경우를 생각해보면,
    리프레시 토큰의 상태(삽입/삭제)를 동기화해야 함

  • 이를 관리하기 위해 추가적인 설정 및 코드 작성이 필요.

2) 관리 비용 증가

  • 리프레시 토큰은 짧은 수명(예: 7일)을 가지므로 만료된 데이터를 주기적으로 삭제해야 함.

  • DB로 관리한다면 배치 작업(CRON) 또는 TTL 관리 로직이 필요해짐.


3. 보안 문제

1) 데이터베이스 해킹

  • DB에 리프레시 토큰을 저장하면, DB 해킹 시 리프레시 토큰이 유출될 가능성이 있음.
  • 리프레시 토큰은 액세스 토큰을 재발급받을 수 있는 민감한 정보이므로, 유출 시 피해가 클 수 있음.

2) 중앙 저장소 의존성

  • 리프레시 토큰이 DB에 저장되면, 토큰 검증 및 관리가 DB에 지나치게 의존적이 됨.
  • 분산 시스템에서 장애 복구 또는 데이터 손실 문제가 발생하면, 사용자 로그인이 불가능해질 수 있음.

대안: Redis를 사용한 리프레시 토큰 관리

Redis와 같은 메모리 기반 저장소TTL(Time-To-Live)을 지원하므로,
토큰 만료를 자동으로 관리할 수 있음.

1) 성능 향상

  • Redis는 메모리 기반이므로 DB에 비해 읽기/쓰기 속도가 훨씬 빠름.

  • 수백만 건의 요청도 낮은 지연 시간으로 처리 가능.

2) 간단한 TTL 관리

  • 토큰 생성 시 TTL(Time-To-Live)을 설정하면, 만료된 데이터를 자동으로 삭제.

  • 주기적인 데이터 정리가 필요 없음.

3) 분산 환경에 적합

  • Redis는 클러스터링분산 캐시를 지원하므로, 대규모 트래픽 처리에 적합.

  • 여러 인스턴스 간의 데이터 동기화 문제를 쉽게 해결 가능.

Redis 를 사용하여 리프레시 토큰을 관리하는 방법에서 얻을 수 있는 이득이 크다고 판단!
현재 MySQL DB 에 저장하던 리프레시 토큰을 Redis 로 사용하도록 변경하려고 한다.

Redis, 리프레시 토큰 관련하여 좋은 글이 있어 참고하였다.

RefreshToken은 왜 Redis를 사용해 관리할까?



5. Redis 를 사용하여 리프레시 토큰을 저장하자!

1) 레디스 설치

우선 Redis 를 사용하기 위해서는 설치를 진행해야한다.

Redis 설치 Windows

블로그 글을 참고하여 간편하게 설치를 하였고
편의를 위해 설치 후 비밀번호 설정은 하지 않고 진행할 예정이다.


2) 레디스 프로젝트에 적용

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

우선 gradle 에 redis 의존성을 추가해주어야 한다.

spring:
    data:
      redis:
        host: localhost
        port: 6379

또한 application.yml 파일에 spring -> data -> redis 에 대한 host,port 를 설정
지금은 로컬에서 실행할 것이라 상관없지만 나중에 배포를하게 된다면
환경변수로 관리할 필요성이 있어보인다!


3) RedisConfig 파일 생성

  • RedisConfig
@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    /**
     * Redis 서버와 애플리케이션 간 연결을 설정하고 관리, LettuceConnectionFactory 사용
     *
     * @return 연결 팩토리를 생성
     */
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        //LettuceConnectionFactory 는 Lettuce 클라이언트를 사용하여 연결 팩토리를 생성해주는 역할
        //호스트와 포트 정보를 사용하여 Redis 서버와의 연결 설정을 해줌.
        return new LettuceConnectionFactory(host, port);
    }

    /**
     * Redis 와의 데이터 입출력을 위한 주요 인터페이스 RedisTemplate Key-Value 구조로 데이터를 저장, 조회, 삭제 등의 작업을 수행하는 역할
     * 직렬화,역직렬화 설정을 통해 데이터 형식을 관리할 수 있음
     *
     * @return 설정된 template 리턴
     */
    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> template = new RedisTemplate<>();

        // Redis에 저장할 Key를 String 형태로 직렬화
        template.setKeySerializer(new StringRedisSerializer());

        // Redis에 저장할 Value를 String 형태로 직렬화
        template.setValueSerializer(new StringRedisSerializer());

        // Redis 연결 팩토리 설정
        template.setConnectionFactory(redisConnectionFactory());

        return template;
    }
}

Java의 Redis Client는 크게 Jedis, Lettuce 2가지가 있다.
나는 Lettuce를 사용하기로 결정했다.

Jedis와 Lettuce의 차이

특징JedisLettuce
스레드 안전성스레드 비안전 (싱글 스레드 전용)스레드 세이프 (싱글 및 멀티 스레드에서 사용 가능)
비동기 지원비동기 미지원비동기 및 동기 방식 모두 지원
Reactive 지원지원하지 않음Reactive Streams 지원
성능멀티스레드 환경에서 추가 설정 필요멀티스레드 환경에서도 기본적으로 안전
사용 편의성동기 방식 전용으로 간단한 구현고급 기능과 설정이 가능
  • Lettuce를 선택한 이유
  1. 스레드 안전: Lettuce는 멀티스레드 환경에서도 안전하므로 추가 설정 없이 여러 스레드에서 동시에 사용할 수 있다.

  2. 비동기 및 Reactive 지원: 현대의 비동기 기반 애플리케이션(Spring WebFlux 등)과 잘 통합된다.

  3. 성능 우수: Jedis와 비교해 더 나은 성능과 확장성을 제공한다.

참고한 블로그 글도 올려두겠다.
Jedis 보다 Lettuce를 쓰자


4) 로그아웃 과정 변화

로그인 과정은 딱히 변한 것이 없다.
하지만 로그아웃 과정에 추가된 부분이 있어 설명하고자 한다.

/**
 * 사용자 로그아웃
 *
 * @param request        HTTP 요청 정보를 담고있는 객체
 * @param response       HTTP 응답 정보를 담고있는 객체
 * @param authentication 토큰을 통해 얻어온 사용자 정보를 담고있는 인증 객체
 * @param refreshToken   클라이언트에서 쿠키로 전달된 리프레시 토큰 값 (선택 사항)
 * @param authHeader     클라이언트에서 Authorization 헤더로 전달된 액세스 토큰 값 (선택 사항)
 * @return 정상적으로 로그아웃 처리 시 OK 상태코드를 반환
 * @throws UsernameNotFoundException 로그인되지 않은 상태에서 로그아웃을 시도한 경우 발생
 */
@PostMapping("/logout")
public ResponseEntity<Void> logout(
    HttpServletRequest request,
    HttpServletResponse response,
    Authentication authentication,
    @CookieValue(value = "refreshToken", required = false) String refreshToken,
    @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authHeader
) {
    // 인증 객체가 null이 아니고, 인증된 상태인지 확인
    if (authentication != null && authentication.isAuthenticated()) {
        try {
            // 1. SecurityContext에서 사용자 정보 정리
            new SecurityContextLogoutHandler().logout(request, response, authentication);

            // 2. 클라이언트에서 전달된 리프레시 토큰이 존재하면 삭제
            if (refreshToken != null) {
                refreshTokenService.deleteRefreshToken(authentication); // DB 또는 Redis에서 삭제
            }

            // 3. Authorization 헤더에 Bearer 토큰이 포함되어 있다면 블랙리스트에 추가
            if (authHeader != null && authHeader.startsWith("Bearer ")) {
                String accessToken = authHeader.substring(7); // "Bearer " 접두어 제거
                refreshTokenService.addToBlacklist(accessToken); // 블랙리스트에 토큰 추가
            }

            // 4. 리프레시 토큰 쿠키 만료 처리
            ResponseCookie expiredCookie = ResponseCookie.from("refreshToken", "") // 빈 값 설정
                .httpOnly(true) // JavaScript에서 접근 불가
                .path("/") // 쿠키의 경로 설정
                .maxAge(0) // 만료 시간 0으로 설정
                .secure(false) // HTTPS에서만 사용 여부 (false: HTTP에서도 사용 가능)
                .build();

            // 성공적으로 로그아웃 처리된 경우 응답 반환
            return ResponseEntity.ok()
                .header(HttpHeaders.SET_COOKIE, expiredCookie.toString()) // 만료된 쿠키 추가
                .build();
        } catch (Exception ex) {
            // 예기치 못한 에러가 발생한 경우 로그 기록 후 서버 오류 응답 반환
            log.error("로그아웃 처리 중 예외 발생", ex);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    // 인증 객체가 null이거나 인증되지 않은 상태인 경우 예외 발생
    throw new UsernameNotFoundException("로그인이 먼저 필요합니다.");
}

뭔가 많이 추가된 느낌이지만 실제로는 액세스 토큰을 블랙리스트에 추가하여
해당 액세스 토큰이 만료시간이 남아있다 하더라도 접근하지 못하게 블랙리스트에 추가하는
과정만 추가되었다.


5) RefreshTokenService

코드가 길어 링크로 대신하고 간단히 설명하겠다.

  1. RedisConfig 에서 설정한 redisTemplate 을 주입받아 redis 명령어 실행

  2. MySQL DB 에 저장하지않고 인메모리 DB인 redis 에 저장하며 만료 시간 설정
    -> 따라서 Entity 나 repositry 가 필요없어짐

  3. RefreshTokenService 는 Security Filter 에 걸리지않는 화이트 리스트 경로이므로
    해당 refresh token 이 올바른지 검증하는 과정을
    validateRefreshToken 메서드를 사용하여서비스 단에서 진행

  4. 로그아웃 시 호출하는 메서드 deleteRefreshToken, addToBlacklist 메서드 구현
    전자는 리프레시 토큰을 redis 에서 삭제하여 사용하지 못하도록 하는 것

    후자는 같이 들어온 액세스 토큰의 만료 기간이 남아있다면
    블랙리스트로 redis 에 저장하여 사용하지 못하도록 하는 것


6) 실제로 테스트 해보자!

프론트 단에서 서버에 존재하는 유저의 이메일과 비밀번호로 로그인 요청

실제로 Redis 에 설정한 만료시간대로 리프레시 토큰이 저장된 모습

이 상태에서 로그아웃을 진행

리프레시 토큰은 삭제되고 액세스 토큰이 블랙리스트로 들어간 모습을 확인할 수 있다.

profile
개발일기

0개의 댓글

관련 채용 정보