`Spring Security + JWT 적용기 1편: 로그인`에서 이어지는 글입니다.
이전 글에서 Spring Security + JWT를 통한 로그인을 통해 서버에서 JWT를 생성해서 클라이언트에 보내주었다. 이제 클라이언트에서 요청과 함께 전달한 JWT Access Token을 검증해야 한다.
JWT 검증 흐름
- 생성한 Access Token과 Refresh Token을 DB에 저장한 후 HttpServletResponse 헤더에 담아 전달한다.
- 클라이언트에서 Request와 함께 JWT를 헤더에 담아 서버에 보낸다.
- 서버는 JWT의 Access Token이 유효한지 확인한다.
- Access Token이 유효하다면 Token에서 유저 정보를 담은 Authentication 객체를 생성해 SecurityContextHolder의 SecurityContext에 저장하여 전역적으로 참조한다.
- 클라이언트의 요청을 수락한다.
사용자마다 다른 Authentication 객체를 어떻게 구분할까?
각각의 사용자를 어떻게 구분할 수 있는 이유는 SecurityContextHolder는 ThreadLocal에 저장되기 때문에 각기 다른 Thread별로 다른 SecurityContextHolder 인스턴스를 가지고 있기 때문이다. 이 덕분에 사용자 별로 각기 다른 Authentication 객체를 가질 수 있다.
SecurityContext
Authentication 객체가 저장되는 보관소이며 언제든지 Authentication 객체를 꺼내어 사용할 수 있도록 제공되는 클래스이다.
ThreadLocal에 저장되어 전역적으로 참조가 가능하다. ThreadLocal이기 때문에 Thread마다 할당된 고유 공간이어서 다른 Thread와 공유되지 않는다.
get, set, remove api를 지원한다. 전역적으로 참조가 가능하기 때문에 set한 이후 전역적으로 get할 수 있다. (A 메서드에서 set을 한 이후 B메서드에서 get으로 조회할 수 있다.)
SecurityContextHolder
SecirotuContext 객체를 저장하는 wrapper 클래스이다. SecurityContext를 저장하는 방식으로 3가지가 있다.
- MODE_THREADLOCAL : Thread 당 SecurityContext 객체 할당
- MODE_INHERITABLETHREADLOCAL : Parent Thread와 Child Thread에 동일한 SecurityContext를 할당
- MODE_GLOBAL : 응용 프로그램에서 단 하나의 SecurityContext를 저장
SecurityContextHolder.*getContext*().setAuthentication(authentication) : Authentication 객체를 저장
SecurityContextHolder.clearContext() : 기본 정보를 초기화
SecurityContextHolder.*getContext*().getAuthentication() : Authentication 객체를 조회 (어떤 메서드에서든 조회 가능)
구현
JwtVerificationFilter
JWT를 검증 후 검증에 성공하면 SecurityContext에 저장하는 필터. OncePerRequestFilter를 상속받고 있다.
OncePerRequestFilter는 각 HTTP 요청에 대해 한 번만 실행되는 것을 보장한다. HTTP 요청마다 JWT를 검증하는 것은 비효율적이기 때문에 OncePerRequestFilter를 상속함으로써 JWT 검증을 보다 효율적으로 수행할 수 있다.
@Slf4j
@RequiredArgsConstructor
public class JwtVerificationFilter extends OncePerRequestFilter {
// 인증에서 제외할 url
private static final List<String> EXCLUDE_URL =
List.of("/",
"/h2",
"/members/signup",
"/auth/login",
"/auth/reissue");
private final JwtTokenProvider jwtTokenProvider;
private final RedisService redisService;
// JWT 인증 정보를 현재 쓰레드의 SecurityContext에 저장(가입/로그인/재발급 Request 제외)
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
String accessToken = jwtTokenProvider.resolveAccessToken(request);
if (StringUtils.hasText(accessToken) && doNotLogout(accessToken)
&& jwtTokenProvider.validateToken(accessToken, response)) {
setAuthenticationToContext(accessToken);
}
// TODO: 예외처리 리팩토링
} catch (RuntimeException e) {
if (e instanceof BusinessLogicException) {
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writeValueAsString(ErrorResponse.of(((BusinessLogicException) e).getExceptionCode()));
response.getWriter().write(json);
response.setStatus(((BusinessLogicException) e).getExceptionCode().getStatus());
}
}
filterChain.doFilter(request, response);
}
private boolean doNotLogout(String accessToken) {
String isLogout = redisService.getValues(accessToken);
return isLogout.equals("false");
}
// EXCLUDE_URL과 동일한 요청이 들어왔을 경우, 현재 필터를 진행하지 않고 다음 필터 진행
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
boolean result = EXCLUDE_URL.stream().anyMatch(exclude -> exclude.equalsIgnoreCase(request.getServletPath()));
return result;
}
private void setAuthenticationToContext(String accessToken) {
Authentication authentication = jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("# Token verification success!");
}
}
doFilterInternal() : HTTP 요청(reqeust)에서 JWT을 꺼내 검증한 후 검증이 되면 SecurityContext에 저장하는 메서드
- StringUtils.*hasText*(accessToken) : Access Token이 존재하는지 확인
- doNotLogout(accessToken) : 로그아웃 처리한 Access Token인지 확인한다. 로그아웃 관련 내용은 다음 글에서 다룰 예정이다.
- jwtTokenProvider.validateToken(accessToken, response) : JWT 토큰을 검증한다.
- shouldNotFilter() : EXCLUDE_URL에 정의된 URL은 JWT를 검증하지 않고 다음 필터를 진행하도록 설정해주는 메서드
public boolean validateToken(String token, HttpServletResponse response) {
log.info("JwtTokenProvider.validateToken excute, token = {}", token);
try {
parseClaims(token);
} catch (MalformedJwtException e) {
log.info("Invalid JWT token");
log.trace("Invalid JWT token trace = {}", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT token");
log.trace("Expired JWT token trace = {}", e);
Responder.sendErrorResponse(response, ExceptionCode.TOKEN_EXPIRED);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT token");
log.trace("Unsupported JWT token trace = {}", e);
Responder.sendErrorResponse(response, ExceptionCode.TOKEN_UNSUPPORTED);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.");
log.trace("JWT claims string is empty trace = {}", e);
Responder.sendErrorResponse(response, ExceptionCode.TOKEN_ILLEGAL_ARGUMENT);
}
return true;
}
테스트
검증이 잘 진행되는지 통합 테스트를 해보자.
@Slf4j
@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@PostMapping("/signup")
public ResponseEntity signUp(@Valid @RequestBody MemberDto.SignUp signUpDto) {
MemberDto.Response response = memberService.signUp(signUpDto);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.CREATED);
}
@GetMapping
public ResponseEntity getMember(@AuthenticationPrincipal CustomUserDetails user) {
String email = user.getEmail();
MemberDto.Response response = memberService.findMemberByEmail(email);
return new ResponseEntity<>(new SingleResponseDto<>(response), HttpStatus.OK);
}
}
현재 회원 조회 메서드는 @AuthenticationPrincipal 애너테이션을 사용해 SecurityContext에 저장된 Authentication 객체에서 User의 email을 통해 DB에 저장된 Member를 조회하는 로직이다.
AuthenticationPrincipal이 정상적으로 동작하려면 JWT 검증을 통해 접근한 사용자의 Authentication 객체가 SecurityContext에 저장되어야 한다.
class MemberIntegrationTest extends BaseIntegrationTest {
private final String BASE_URL = "/members";
private final String EMAIL = "email@gmail.com";
@Autowired
private MemberService memberService;
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Autowired
private AES128Config aes128Config;
@BeforeEach
void befroeEach() {
MemberDto.SignUp signUpDto = StubData.MockMember.getSignUpDto();
memberService.signUp(signUpDto);
}
@AfterEach
void afterEach() {
memberService.deleteMember(EMAIL);
}
@Test
@DisplayName("회원 조회")
void getMemberTest() throws Exception {
// given
CustomUserDetails userDetails = StubData.MockMember.getUserDetails();
TokenDto tokenDto = jwtTokenProvider.generateTokenDto(userDetails);
String accessToken = tokenDto.getAccessToken();
String refreshToken = tokenDto.getRefreshToken();
String encryptedRefreshToken = aes128Config.encryptAes(refreshToken);
// when
String uri = UriComponentsBuilder.newInstance().path(BASE_URL)
.build().toUri().toString();
ResultActions actions = ResultActionsUtils.getRequestWithToken(mvc, uri, accessToken, encryptedRefreshToken);
// then
actions
.andExpect(status().isOk())
.andDo(document("get-member",
getResponsePreProcessor(),
MemberResponseSnippet.getMemberResponseSnippet()));
}
}
@BeforeEach로 테스트 실행 전에 User를 생성해 회원가입을 진행한다. 회원가입한 User 정보로 Access Token과 Refresh Tokne을 한 후MockMvc 헤더에 담아 전달한다.
만약 테스트가 통과한다면 검증 로직을 무사히 넘기고 SecurityContext에 Authentication이 잘 저장되었다는 것이다.
테스트가 무사히 통과되었다!
위 통합 테스트에 대해 자세하게 정리한 내용은 아래 링크를 통해 확인할 수 있다.
마무리
다음은 Spring Security + JWT + Redis를 사용한 로그아웃에 대해 알아보자
'Spring' 카테고리의 다른 글
Spring - Spring Security + JWT 4편: Access Token 재발급 (2) | 2023.04.14 |
---|---|
Spring - Spring Security + JWT 적용기 3편: 로그아웃 (3) | 2023.04.14 |
Spring - Spring Security + JWT 적용기 1편: 로그인 (5) | 2023.04.14 |
Spring - 나의 첫 통합 테스트(Integration Test) (0) | 2023.04.12 |
Spring - Spring Profile로 다양한 개발 환경 설정 관리하기 (0) | 2023.04.12 |