JWT 토큰 관리에 관한 고찰

텐저린티·2024년 1월 18일
1
post-thumbnail

팀프로젝트에서 JWT 를 이용해 인증 기능을 구현했다.
지난번 [[카페인 캐시와 캐시 추상화]] 포스팅은 JWT 관리를 로컬 캐시에서 처리하는 내용을 다뤘다.
이번 포스팅에서는 로그인, 토큰 갱신, 로그아웃, 회원탈퇴 같은 인증 관련 기능을 다룰 예정이다.

🎯 사건의 발단

회원탈퇴 기능을 구현하고 있었다.
회원탈퇴에서 수행하는 작업은 크게 세 가지다.

회원탈퇴 시 작업
1. DB에서 회원정보 제거
2. S3 버킷에서 프로필 이미지 제거
3. 로컬 캐시에서 JWT 제거

지난 포스팅에서 설명한 캐시 추상화는 3번 로컬 캐시를 제거하면서 도입했다.
추상화를 적용하면서 불필요한 캐시나 로그아웃 필터, 제대로 동작하지 않는 로직을 발견했다.
JWT 자체를 제대로 쓰고 있지 않다는 생각이 들었고, 개념부터 확실하게 짚고 넘어가는게 좋을 것 같았다.

암튼 그래서 문제점을 요약정리하면,

기존 인증 문제
1. 불필요한 캐시 존재
2. 불필요한 로그아웃 필터
3. 제대로 동작하지 않는 인증 로직

이 문제점을 해결한 과정에 JWT 설정하고 사용하는 기본기까지 살짝 얹어서 풀어가고자 한다.

🔍 톺아보기

JWT는 OOOO다

상태가 없다?!?

JWT는 stateless 다.

서버가 클라의 상태를 보존하지 않는다는 무상태성을 말한다.

이렇게 header, payload, signature로 구성된 토큰을 서버 요청의 헤더 중 Authentication 속성에 넣어준다.
인증/인가에 대한 모든 정보가 하나의 토큰에 담겨있다.
모든 정보가 담겨 있기 때문에 서버 입장에서는 인증/인가 혹은 사용자에 대한 정보를 저장할 필요 없이 인증/인가 관련된 정보를 추출하여 구현할 수 있다.

모바일 앱에서는 쿠키를 사용할 수 없는데, 이러한 경우에 JWT를 활용한 토큰 인증을 사용하면 찰떡이다.

stateful 인증 방식인 Session이 있는데, 이 친구와 비교해보면 이해가 쉽다.

SessionJWT
서버 메모리에 유저 정보 저장 O서버 메모리에 유저 정보 저장 X
요청 시 해당 정보를 조회해서 인증,인가 수행요청 시 헤더에 있는 토큰을 파싱하여 인증, 인가 수행
서버 자원을 사용하므로, 서버 과부화에 취약모든 인증/인가 관련 데이터가 전달되므로 요청/응답 무거움
단일 서버인 경우 효율적서버 확장성 관점에서 세션보다 유리
HTTP 프로토콜의 Stateless 특성 무효화HTTP 프로토콜의 Stateless 특성 만족

하지만 우리 서버는 상태 있음.. ㅠ

하지만 앞으로 우리가 구현할 JWT 토큰 기반 인증은 Stateless 가 아니라 Stateful이다.

사람 놀리나 싶겠지만 이유가 있다.
JWT 에 있는 보안 문제점 때문이다.

  • XSS(Cross Site Scripting) 문제
    - 토큰을 로컬 스토리지에 저장하면 XSS 공격으로 삽입된 악의적인 코드가 토큰을 탈취할 수 있다.
  • 토큰이 탈취되어도 서버 입장에서는 알 수 없기 때문에 보안에 취약
    - 해킹됐는지도, 막을수도 없는 상태가 됨.

이러한 문제를 해결하기 위해서 취할 수 있는 전략은 의외로 간단하다.

토큰 자체의 수명을 짧게 설정하면 된다.

다만, 이러한 경우 방금 로그인을 했는데 몇 초 후 새로고침하면 다시 로그인을 해야하는 일이 발생한다.
아주 불편한 일이 생겨버린거다.

그래서 Refresh Token 이 왜 필요함?

토큰 수명이 줄어서 생긴 문제점을 해결하기 위해서 Refresh Token 이 나왔다.
로그인을 자주 하고 싶지 않으니, 수명이 긴 토큰을 하나 더 만들어서 이를 이용해서 로그인을 하지 않도록 하는거다.

Access TokenRefresh Token
수명 짧은 토큰수명 긴 토큰

이렇게 토큰을 1:1로 매핑해두자.

수명이 다한 토큰으로 요청을 보냈을 때 해당 토큰과 매핑된 Refresh Token 을 확인하고, 해당 토큰이 유효하다면 새로운 토큰을 발급해주는거다.

사용자 입장에서는 이런 방식으로 수명 짧은 토큰을 사용해도 로그인을 자주 하지 않을 수 있다.

그럼 이제 왜 우리가 만드는 토큰 기반 인증이 stateful인지 이해했을것 같다.
AccessToken을 갱신하기 위해서 RefreshToken을 저장해야 하기 때문이다.

당신의 노파심
참고로 RefreshToken도 탈튀되면 위험한 거 아닌가 생각할 수도 있다.
RefreshToken을 클라에서 알 수 없도록 서버 안에서만 가지고 있으면 된다.
응답으로 주지 말라는 소리다. 괜한 걱정이란 말이기도 하고.

로그아웃 방식 두 가지

로그아웃 기능을 구현하는데는 두 가지 방법이 있다.
결론부터 말하자면, 나는 마지막 방법을 활용할거다.
첫 번째 방법보다 훨씬 낫다고 생각하기 때문이다.

블랙 리스트 방식 (추가 방식)

로그아웃 요청과 함께 온 Access Token 을 따로 저장해두는 방식이다.
로그인 필터나 로그아웃 필터에서 해당 Access Token 이 블랙 리스트에 있는지 확인하고, 있다면 로그아웃으로 판단해 요청을 거부하는 것이다.
자연스럽게 토큰 저장소가 두 개 필요하게 된다.

제대로 동작은 하겠다만 나는 이 방식보다 아래 방법을 쓸거다.

삭제 방식

AccessToken : RefreshToken 매핑된 토큰 저장소 하나만 있으면 되는 방식이다.

  1. 로그아웃 시 저장소에서 해당 AccessToken과 RefreshToken 제거
  2. 로그인 필터에서 요청에 있는 토큰이 저장소에 있는지 확인, 저장소에 없으면 로그아웃으로 판단

추가적인 저장소가 필요하지도, 로그아웃 필터가 따로 필요하지도 않기 때문에 나는 이 방식을 쓰기로 했다.

🏗️ 구조

이런 꼴을 갖는다.
OAuth2 를 사용한다고 해도, Spring Security 사이에 필터가 하나 더 추가한 느낌으로 별반 다르지 않을거다.

🧳 준비물

Gradle 의존성

// JWT  
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'  
implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'  
implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

Security 설정

// Spring Security  
implementation 'org.springframework.boot:spring-boot-starter-security'  
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'  
testImplementation "org.springframework.security:spring-security-test"
// 인증 허가된 endpoint 목록
// refresh, logout 은 인증이 필요하도록 제외했다.
public class PermitAllEndpoint {  
  
    private PermitAllEndpoint() {  
    }  
    protected static final String[] permitAllArray = new String[]{  
            "/",  
            "/auth/kakao"
    };  
}

JWT 필터 적용

  
@Component  
public class JwtAuthenticationFilter extends OncePerRequestFilter {  
  
    private static final String HEADER_AUTHORIZATION = "Authorization";  
    private static final String TOKEN_PREFIX = "Bearer ";  
  
    private final AuthTokenProvider tokenProvider;  
    private final AuthService authService;  
  
    public JwtAuthenticationFilter(AuthTokenProvider tokenProvider, AuthService authService) {  
        this.tokenProvider = tokenProvider;  
        this.authService = authService;  
    }  
  
    @Override  
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,  
            FilterChain filterChain) throws ServletException, IOException {  
  
        final String AUTHORIZATION_HEADER = request.getHeader(HEADER_AUTHORIZATION);  
  
        if (AUTHORIZATION_HEADER != null && AUTHORIZATION_HEADER.startsWith(TOKEN_PREFIX)) {  
            String tokenStr = JwtHeaderUtil.getAccessToken(request);  
            AuthToken token = tokenProvider.convertAuthToken(tokenStr);  

			// isLogout 메소드로 로그아웃 여부를 확인한다.
			// 최종적으로 인증이 성공하면 SecurityContextHolder.Context 에 담아 요청 스레드 전반에 걸쳐서 사용한다.
            if (token.isValidTokenClaims() && !authService.isLogout(token.getToken())) {  
                Authentication authentication = tokenProvider.getAuthentication(token);  
                SecurityContextHolder.getContext().setAuthentication(authentication);  
            }  
        }  
  
        filterChain.doFilter(request, response);  
    }  
  
}
@Configuration  
@EnableWebSecurity  
public class SecurityConfig {  
  
    private final SecurityExceptionHandlerFilter securityExceptionHandlerFilter;  
    private final JwtAuthenticationFilter jwtTokenValidationFilter;  
  
    public SecurityConfig(  
            SecurityExceptionHandlerFilter securityExceptionHandlerFilter,  
            JwtAuthenticationFilter jwtTokenValidationFilter  
    ) {  
        this.securityExceptionHandlerFilter = securityExceptionHandlerFilter;  
        this.jwtTokenValidationFilter = jwtTokenValidationFilter;  
    }  
  
    @Bean  
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {  
        return http  
                .authorizeHttpRequests(request -> request  
                        .requestMatchers(  
                                Stream  
                                        .of(PermitAllEndpoint.permitAllArray)  
                                        .map(AntPathRequestMatcher::antMatcher)  
                                        .toArray(AntPathRequestMatcher[]::new)  
                        )  
                        .permitAll()  
                        .anyRequest().authenticated())  
                .cors(cors -> corsConfigurationSource())  
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))  
                .csrf(AbstractHttpConfigurer::disable)  
                .httpBasic(HttpBasicConfigurer::disable)  
                .formLogin(AbstractHttpConfigurer::disable)  
                .addFilterBefore(jwtTokenValidationFilter, UsernamePasswordAuthenticationFilter.class)  
                .addFilterBefore(securityExceptionHandlerFilter, JwtAuthenticationFilter.class)  
                .build();  
    }

📺 진행과정

JWT 편의 클래스 만들기

만든 클래스는 다음과 같다.
구체적인 구현은 차치하고 어떤게 필요하냐면,

  • 토큰 객체
  • 토큰 생성 객체

이렇게 만들어두고 쓰면 편하다

로그아웃 캐시 제거하기

앞서 로그아웃 방식이 두 개 있다고 했다.
첫 번째는 블랙 리스트 방식.
두 번째는 그냥 토큰 추가/삭제 방식

맨 처음에는 아래처럼 블랙리스트 방식으로 구현했다.

RefreshCache
KeyValue
Access TokenRefresh Token
LogoutCache
KeyValue
Access TokenAccess Token

쉽게 설명해서 평소에 Access Token 을 이용해서 인증과 토큰 갱신을 수행하고,
로그아웃 시에 Access Token 을 따로 저장해서 자동 인증을 방지하는 거다.

로그아웃 캐시는 제거되지 않으므로 비대해질 가능성이 크다.
주기적으로 제거하는 요령이 필요하다.
아니면 캐시 속성 중에 expirateAfterWrite 이라는 속성을 CacaheManager 를 통해 설정할 수 있는데,
이 속성을 이용해서 리프레시 토큰 만료일 후에는 자동으로 해당 캐시값을 제거하도록 구현해도 좋다.

암튼 두 번째 방식으로 하게 된다면
우리는 Refresh Cache 하나만 필요하게 된다.

인증 로직 수정하기

로그인 로직 Before 리팩토링

  1. 클라의 요청 수신
  2. 스프링 세큐리티 필터 중 인증 예외 필터에 걸림
  3. 스프링 세큐리티 필터 중 로그아웃 필터에 걸림
    1. 해당 토큰이 LogoutCache에 있으면 예외 터트리고 필터 적용 안 함
  4. 스프링 세큐리티 필터 중 로그인 필터(JWT 필터)에 걸림
    1. 정당한 사용자인 경우 토큰 발급
    2. 정당하지 않은 사용자인 경우 예외 터트리고 필터 적용 안 함

After 리팩토링

  1. 클라의 요청 수신
  2. 스프링 세큐리티 필터 중 인증 예외 필터에 걸림
  3. 스프링 세큐리티 필터 중 로그인 필터에 걸림
    1. 토큰이 RefreshCache 에 있으면 로그인 성공
    2. 토큰이 RefreshCacahe 에 없으면 로그아웃
      1. 예외터트리고 더이상 진행하지 않음

로그아웃 필터 제거하기

위에서 보았듯이 로그아웃 필터가 필요 없다.
엄밀히 말하자면, 블랙 리스트를 위해 필요한 LogoutCache 를 제거하기 위해선 로그아웃 필터를 제거해야 한다.

로그인 필터보다 로그아웃 필터가 무조건 앞에 와야 하는데,
만약 처음으로 회원가입을 하는 유저인 경우 무조건 우리 서버 내 RefreshCache에는 토큰이 없어서 회원가입에 무한히 실패하게 된다.

따라서 로그아웃 필터를 제거하고, 로그인 필터에서 로그아웃 여부를 판단하는게 좋다.

인증 무시 엔드포인트 설정

이전까지 인증을 무시하고 접근을 허용하는 엔드포인트에 치명적인 보안 구멍이 있었다.
인증 관련 API 를 모두 뚫어놨기 때문에, 다른 사람을 로그아웃 시킬 수도 있고, 토큰을 재발급 받을 수도 있었다.
미친거다.
이 부분을 개선하고자, 인증 관련 API에서 로그인 관련 엔드포인트만 명시적으로 선언해서 다른 요청은 403 Not Authenticated 에러를 응답하도록 리팩토링했다.

회원탈퇴 로직 구현

회원 도메인에서 DB 내 모든 회원 정보를 제거한 후에 스프링 이벤트리스너를 통해 로컬 캐시에 있는 해당 회원의 토큰 정보를 제거한다.

토큰 정보를 제거하는건 로그아웃 기능이므로, 해당 메소드를 호출하는 것으로 기능 구현을 마쳤다.

🔑 결론

캐시는 자주 요청되는 작업을 가까운 곳에 저장해두어 응답시간을 줄이는 것이 목적이다.
하지만 우리는 캐시를 토큰 저장에 사용했다.
캐시를 제대로 활용했다고 보긴 어렵다.

하지만, JWT 인증을 편리하고 안정적으로 유지하고 구현하기 위해서 선택해야할 몇 가지 선택 중에선 가장 최선의 선택을 했다고 생각한다.

DB, REDIS, CACHE.

단일 서버에서 인증 관련 부하를 줄이고, 토큰 관리를 안정적으로 할 수 있는 기술이 캐시 말고 더 있는가?

있으면 공유좀. 궁금함.

🔗 참조

JWT 공식

profile
개발하고 말테야

0개의 댓글