팀프로젝트에서 JWT 를 이용해 인증 기능을 구현했다.
지난번 [[카페인 캐시와 캐시 추상화]] 포스팅은 JWT 관리를 로컬 캐시
에서 처리하는 내용을 다뤘다.
이번 포스팅에서는 로그인
, 토큰 갱신
, 로그아웃
, 회원탈퇴
같은 인증 관련 기능을 다룰 예정이다.
회원탈퇴 기능을 구현하고 있었다.
회원탈퇴에서 수행하는 작업은 크게 세 가지다.
회원탈퇴 시 작업
1. DB에서 회원정보 제거
2. S3 버킷에서 프로필 이미지 제거
3. 로컬 캐시에서 JWT 제거
지난 포스팅에서 설명한 캐시 추상화는 3번 로컬 캐시를 제거하면서 도입했다.
추상화를 적용하면서 불필요한 캐시나 로그아웃 필터, 제대로 동작하지 않는 로직을 발견했다.
JWT 자체를 제대로 쓰고 있지 않다는 생각이 들었고, 개념부터 확실하게 짚고 넘어가는게 좋을 것 같았다.
암튼 그래서 문제점을 요약정리하면,
기존 인증 문제
1. 불필요한 캐시 존재
2. 불필요한 로그아웃 필터
3. 제대로 동작하지 않는 인증 로직
이 문제점을 해결한 과정에 JWT 설정하고 사용하는 기본기까지 살짝 얹어서 풀어가고자 한다.
JWT는 stateless
다.
서버가 클라의 상태를 보존하지 않는다는 무상태성
을 말한다.
이렇게 header
, payload
, signature
로 구성된 토큰을 서버 요청의 헤더 중 Authentication 속성에 넣어준다.
인증/인가에 대한 모든 정보가 하나의 토큰에 담겨있다.
모든 정보가 담겨 있기 때문에 서버 입장에서는 인증/인가 혹은 사용자에 대한 정보를 저장할 필요 없이 인증/인가 관련된 정보를 추출하여 구현할 수 있다.
모바일 앱에서는 쿠키를 사용할 수 없는데, 이러한 경우에 JWT를 활용한 토큰 인증을 사용하면 찰떡이다.
stateful
인증 방식인 Session
이 있는데, 이 친구와 비교해보면 이해가 쉽다.
Session | JWT |
---|---|
서버 메모리에 유저 정보 저장 O | 서버 메모리에 유저 정보 저장 X |
요청 시 해당 정보를 조회해서 인증,인가 수행 | 요청 시 헤더에 있는 토큰을 파싱하여 인증, 인가 수행 |
서버 자원을 사용하므로, 서버 과부화에 취약 | 모든 인증/인가 관련 데이터가 전달되므로 요청/응답 무거움 |
단일 서버인 경우 효율적 | 서버 확장성 관점에서 세션보다 유리 |
HTTP 프로토콜의 Stateless 특성 무효화 | HTTP 프로토콜의 Stateless 특성 만족 |
하지만 앞으로 우리가 구현할 JWT 토큰 기반 인증은 Stateless
가 아니라 Stateful
이다.
사람 놀리나 싶겠지만 이유가 있다.
JWT 에 있는 보안
문제점 때문이다.
이러한 문제를 해결하기 위해서 취할 수 있는 전략은 의외로 간단하다.
토큰 자체의 수명을 짧게 설정하면 된다.
다만, 이러한 경우 방금 로그인을 했는데 몇 초 후 새로고침하면 다시 로그인을 해야하는 일이 발생한다.
아주 불편한 일이 생겨버린거다.
토큰 수명이 줄어서 생긴 문제점을 해결하기 위해서 Refresh Token
이 나왔다.
로그인을 자주 하고 싶지 않으니, 수명이 긴 토큰을 하나 더 만들어서 이를 이용해서 로그인을 하지 않도록 하는거다.
Access Token | Refresh Token |
---|---|
수명 짧은 토큰 | 수명 긴 토큰 |
이렇게 토큰을 1:1로 매핑해두자.
수명이 다한 토큰으로 요청을 보냈을 때 해당 토큰과 매핑된 Refresh Token 을 확인하고, 해당 토큰이 유효하다면 새로운 토큰을 발급해주는거다.
사용자 입장에서는 이런 방식으로 수명 짧은 토큰을 사용해도 로그인을 자주 하지 않을 수 있다.
그럼 이제 왜 우리가 만드는 토큰 기반 인증이 stateful
인지 이해했을것 같다.
AccessToken을 갱신하기 위해서 RefreshToken을 저장해야 하기 때문이다.
당신의 노파심
참고로 RefreshToken도 탈튀되면 위험한 거 아닌가 생각할 수도 있다.
RefreshToken을 클라에서 알 수 없도록 서버 안에서만 가지고 있으면 된다.
응답으로 주지 말라는 소리다. 괜한 걱정이란 말이기도 하고.로그아웃 방식 두 가지
로그아웃 기능을 구현하는데는 두 가지 방법이 있다.
결론부터 말하자면, 나는 마지막 방법을 활용할거다.
첫 번째 방법보다 훨씬 낫다고 생각하기 때문이다.블랙 리스트 방식 (추가 방식)
로그아웃 요청과 함께 온 Access Token 을 따로 저장해두는 방식이다.
로그인 필터나 로그아웃 필터에서 해당 Access Token 이 블랙 리스트에 있는지 확인하고, 있다면 로그아웃으로 판단해 요청을 거부하는 것이다.
자연스럽게 토큰 저장소가 두 개 필요하게 된다.
제대로 동작은 하겠다만 나는 이 방식보다 아래 방법을 쓸거다.
AccessToken : RefreshToken
매핑된 토큰 저장소 하나만 있으면 되는 방식이다.
추가적인 저장소가 필요하지도, 로그아웃 필터가 따로 필요하지도 않기 때문에 나는 이 방식을 쓰기로 했다.
이런 꼴을 갖는다.
OAuth2 를 사용한다고 해도, Spring Security 사이에 필터가 하나 더 추가한 느낌으로 별반 다르지 않을거다.
// 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'
// 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"
};
}
@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();
}
만든 클래스는 다음과 같다.
구체적인 구현은 차치하고 어떤게 필요하냐면,
이렇게 만들어두고 쓰면 편하다
앞서 로그아웃 방식이 두 개 있다고 했다.
첫 번째는 블랙 리스트 방식.
두 번째는 그냥 토큰 추가/삭제 방식
맨 처음에는 아래처럼 블랙리스트 방식으로 구현했다.
RefreshCache
Key | Value |
---|---|
Access Token | Refresh Token |
LogoutCache
Key | Value |
---|---|
Access Token | Access Token |
쉽게 설명해서 평소에 Access Token 을 이용해서 인증과 토큰 갱신을 수행하고,
로그아웃 시에 Access Token 을 따로 저장해서 자동 인증을 방지하는 거다.
로그아웃 캐시는 제거되지 않으므로 비대해질 가능성이 크다.
주기적으로 제거하는 요령
이 필요하다.
아니면 캐시 속성 중에 expirateAfterWrite
이라는 속성을 CacaheManager 를 통해 설정할 수 있는데,
이 속성을 이용해서 리프레시 토큰 만료일 후에는 자동으로 해당 캐시값을 제거
하도록 구현해도 좋다.
암튼 두 번째 방식으로 하게 된다면
우리는 Refresh Cache
하나만 필요하게 된다.
요청 수신
인증 예외 필터
에 걸림로그아웃 필터
에 걸림LogoutCache에 있으면
예외 터트리고 필터 적용 안 함로그인 필터(JWT 필터)
에 걸림토큰 발급
요청 수신
인증 예외 필터
에 걸림로그인 필터
에 걸림RefreshCache 에 있으면 로그인 성공
RefreshCacahe 에 없으면 로그아웃
위에서 보았듯이 로그아웃 필터가 필요 없다.
엄밀히 말하자면, 블랙 리스트를 위해 필요한 LogoutCache 를 제거하기 위해선 로그아웃 필터를 제거
해야 한다.
로그인 필터보다 로그아웃 필터가 무조건 앞에 와야 하는데,
만약 처음으로 회원가입을 하는 유저인 경우 무조건 우리 서버 내 RefreshCache에는 토큰이 없어서 회원가입에 무한히 실패하게 된다.
따라서 로그아웃 필터를 제거하고, 로그인 필터에서 로그아웃 여부를 판단하는게 좋다.
이전까지 인증을 무시하고 접근을 허용하는 엔드포인트에 치명적인 보안 구멍
이 있었다.
인증 관련 API 를 모두 뚫어놨기 때문에, 다른 사람을 로그아웃 시킬 수도 있고, 토큰을 재발급 받을 수도 있었다.
미친거다.
이 부분을 개선하고자, 인증 관련 API에서 로그인 관련 엔드포인트만 명시적으로 선언해서 다른 요청은 403 Not Authenticated 에러를 응답하도록 리팩토링했다.
회원 도메인에서 DB 내 모든 회원 정보를 제거한 후에 스프링 이벤트리스너를 통해 로컬 캐시에 있는 해당 회원의 토큰 정보를 제거한다.
토큰 정보를 제거하는건 로그아웃 기능이므로, 해당 메소드를 호출하는 것으로 기능 구현을 마쳤다.
캐시는 자주 요청되는 작업을 가까운 곳에 저장해두어 응답시간을 줄이는 것이 목적이다.
하지만 우리는 캐시를 토큰 저장에 사용했다.
캐시를 제대로 활용했다고 보긴 어렵다.
하지만, JWT 인증을 편리하고 안정적으로 유지하고 구현하기 위해서 선택해야할 몇 가지 선택 중에선 가장 최선의 선택을 했다고 생각한다.
DB, REDIS, CACHE.
단일 서버에서 인증 관련 부하를 줄이고, 토큰 관리를 안정적으로 할 수 있는 기술이 캐시 말고 더 있는가?
있으면 공유좀. 궁금함.