그동안 미루고 미루어왔던 JwtTokenProvider에 대해 알아보고, 테스트 코드를 작성하는 법을 작성하려 한다.
JwtTokenProvider는 로그인 인증 과정을 처리할 때 반드시 필요한 access Token을 생성해주는 역할을 한다. 이 JwtTokenProvider는 @Configuration 어노테이션으로 스프링 빈으로 관리되고 있는 ArgumentResolver이나 Interceptor에서 사용되고 있기 때문에 @Component 어노테이션으로 스프링 빈 등록을 해주어야 한다. 특히 인터셉터에서 HttpServletRequest로 토큰을 추출해주고, 아규먼트 리졸버에서 토큰값으로 payload를 가져오는 역할을 해주기 때문에 JwtTokenProvider의 역할은 매우 중요하다.
JwtTokenProvider
코드를 보면서 바로 익혀보자. JwtTokenProvider 코드는 아래와 같다.
JwtTokenProvider
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final long validityInMilliseconds;
public JwtTokenProvider(@Value("${security.jwt.token.secret-key}") final String secretKey,
@Value("${security.jwt.token.expire-length}") final long validityInMilliseconds) {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.validityInMilliseconds = validityInMilliseconds;
}
public String createToken(final String payload) { // 1
final Date now = new Date();
final Date validity = new Date(now.getTime() + validityInMilliseconds);
return Jwts.builder()
.setSubject(payload)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String getPayload(final String token) { // 2
return tokenToJws(token).getBody().getSubject();
}
public void validateAbleToken(final String token) {
try {
final Jws<Claims> claims = tokenToJws(token);
validateExpiredToken(claims); // 3
} catch (final JwtException | InvalidLoginException e) {
throw new TokenInvalidSecretKeyException(token);
}
}
private Jws<Claims> tokenToJws(final String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
} catch (final IllegalArgumentException | MalformedJwtException e) { // 4
throw new TokenInvalidFormException();
} catch (final SignatureException e) { // 5
throw new TokenInvalidSecretKeyException(token);
} catch (final ExpiredJwtException e) { // 6
throw new TokenInvalidExpiredException();
}
}
private void validateExpiredToken(final Jws<Claims> claims) {
if (claims.getBody().getExpiration().before(new Date())) {
throw new TokenInvalidExpiredException();
}
}
}
스프링 빈으로 등록이 돼있기 때문에 토큰값과 토큰만료일자 값을 application.yml에 등록하여 숨길 수 있다. 해당 값들은 프로덕션 코드에서는 @Value값으로 가져옴으로써 공개적으로 드러내지 않게할 수 있다. application.yml은 아래와 같이 작성하면 된다.
application.yml
security:
jwt:
token:
secret-key: {긴 임의의 토큰 문자열}
expire-length: 3600000 // 1시간 (ms단위)
1. payload에 저장된 값으로 토큰을 생성해주는 부분이다. payload는 유저의 특정 정보에 해당한다. 예를 들어, 이메일로 사용자를 특정할 수 있다면 payload는 이메일이 될 수 있다. 그러나 성별은 사용자끼리 중복될 수 있으므로 payload값으로 부적절하다.
2. token 값으로 payload를 추출할 수 있게 해주는 부분이다.
3. 토큰이 만료됐는지 여부를 확인해주는 부분이다. 여기서 헷갈리는 부분은 아래와 같다.
if (claims.getBody().getExpiration().before(new Date())) {
throw new TokenInvalidExpiredException();
}
getExpiration()이 현재 시각보다 이전이면 예외를 던지는 부분이다. 이는 getExpiration()이 현재 시각보다 이전일 경우, 즉 현재 시각보다 만료가 먼저됐을 경우에 예외를 발생시킨다는 뜻이다. 반대로 해석하지 않게 조심하자.
4. IllegalArgumentException일 경우는 token이 null일 경우, MalformedJwtException일 경우는 token이 파싱이 제대로 되지 않았을 경우에 발생한다.
5. 토큰의 시크릿키만 잘못된 경우는 누군가가 토큰을 변조했을 확률이 높다. 즉, 해킹의 우려 가능성이 존재하는 것이다. 만약 실수일 경우는 4번에서 적은 MalformedJwtException이 발생했을 가능성이 훨씬 높다. 따라서 이러한 경우의 SignatureException이 발생하는 경우는 별도의 예외를 터뜨리도록 했다.
6. 3번과 겹치는 과정일 수 있지만, 혹시나 중간에 찰나의 타이밍으로 토큰이 만료되거나, 그 외 혹시나 하는 마음으로 토큰 만료 시 예외를 던지는 로직도 작성해주었다.
JwtTokenProvider Test
앞에서 작성했던 코드를 검증하기 위해선 어떤 테스트가 필요할까? 먼저 우리는 세 가지 예외를 발생시켰었다. 토큰 만료일자가 지난 경우, 토큰 형식이 틀린 경우, 토큰 시크릿키가 틀린 경우. 그리고 당연히 올바르게 토큰이 발급되는지도 확인해주어야 한다. 따라서 최소 총 4가지 테스트를 작성하면 되겠다. 아래 테스트 코드에는 총 5가지 검증이 존재하는데, 코드를 보면서 익혀보도록 하자.
@SpringBootTest // 1
@Transactional // 2
class JwtTokenProviderTest {
@Autowired
private JwtTokenProvider jwtTokenProvider;
@Value("${security.jwt.token.secret-key}")
private String secretKey; // 3
private final JwtTokenProvider invalidSecretKeyJwtTokenProvider
= new JwtTokenProvider(
"invalidSecretKeyInvalidSecretKeyInvalidSecretKeyInvalidSecretKey",
8640000L
); // 4
@Test
@DisplayName("토큰이 올바르게 생성된다.") // 5
void createToken() {
final String payload = String.valueOf(1L);
final String token = jwtTokenProvider.createToken(payload);
assertThat(token).isNotNull();
}
@DisplayName("올바른 토큰 정보로 payload를 조회한다.") // 6
@Test
void getPayloadByValidToken() {
final String payload = String.valueOf(1L);
final String token = jwtTokenProvider.createToken(payload);
assertThat(jwtTokenProvider.getPayload(token)).isEqualTo(payload);
}
@DisplayName("유효하지 않은 토큰 형식의 토큰으로 payload를 조회할 경우 예외를 발생시킨다.") // 7
@Test
void getPayloadByInvalidToken() {
assertThatExceptionOfType(TokenInvalidFormException.class)
.isThrownBy(() -> jwtTokenProvider.getPayload(null));
}
@DisplayName("만료된 토큰으로 payload를 조회할 경우 예외를 발생시킨다.")
@Test
void getPayloadByExpiredToken() {
final String expiredToken = Jwts.builder()
.signWith(Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256)
.setSubject(String.valueOf(1L))
.setExpiration(new Date((new Date()).getTime() - 1)) // 8
.compact();
assertThatExceptionOfType(TokenInvalidExpiredException.class)
.isThrownBy(() -> jwtTokenProvider.getPayload(expiredToken));
}
@DisplayName("시크릿 키가 틀린 토큰 정보로 payload를 조회할 경우 예외를 발생시킨다.")
@Test
void getPayloadByWrongSecretKeyToken() {
final String invalidSecretToken = invalidSecretKeyJwtTokenProvider.createToken(String.valueOf(1L)); // 9
assertThatExceptionOfType(TokenInvalidSecretKeyException.class)
.isThrownBy(() -> jwtTokenProvider.getPayload(invalidSecretToken));
}
}
1. 스프링 빈으로 등록되고 있으며, application.yml 값을 사용하고 있으므로 @SpringBootTest 어노테이션을 등록해주자.
2. 테스트 격리를 위해 @Transactional 을 사용해주었다. @Transactional 역할이 궁금하다면 아래 포스팅을 참고하자.
https://kth990303.tistory.com/313
3. application.yml에 저장해둔 토큰값을 @Value로 받아온다. 이는 프로덕션 코드에서 쓰이고 있는 토큰값과 동일하다.
4. 올바르지 않은 시크릿키를 사용하고 있는 테스트용 JwtTokenProvider를 생성해준다. 이 provider를 사용할 때엔 TokenInvalidSecretKeyException()이 발생해야 한다.
5. 토큰이 올바르게 생성됐다면 반드시 null은 아닐 것이며, 아무런 예외에도 걸리지 않아야 한다.
6. 토큰이 올바르게 생성될 때, payload를 올바르게 추출하는지 확인해주는 메서드이다. 보통 payload로는 id값이나 email을 사용한다.
7. 토큰 값이 null인 경우 IllegalArgumentException이 발생하므로 토큰 형식이 올바르지 않다고 예외가 발생할 것이다.
8. 좀 어려운 부분이다. 만료일자가 지난 토큰을 따로 만들어준 후에 테스트해야 한다. 토큰은 Jwt.Builder()를 이용해서 만들 수 있다. 아래와 같이 만료일자를 현재 시각보다 무조건 이전으로 설정해줄 수 있다.
setExpiration(new Date((new Date()).getTime() - 1))
signWith에는 프로덕션 코드에 적용했던 토큰 암호화 라이브러리를 그대로 넣어주도록 하자.
9. 마지막으로 토큰 시크릿 키가 틀린 경우이다. 이 경우는 4번에서 만들어주었던 시크릿키가 틀린 jwtTokenProvider를 사용해주자.
이렇게 하면 JwtTokenProviderTest가 passed할 것이다!
이 테스트가 중요한 이유가, 여기서 테스트들이 실패하여 기능들이 제대로 돌아가지 않는다고 해보자. 그럼 결국 인터셉터, 아규먼트리졸버의 기능도 제대로 돌아가지 않으므로 인증/인가 기능들이 의도하지 않은 채 작동될 수 있다. 실 서비스에서 이러한 현상이 나타나면 큰일날 것이다. 따라서 이러한 중요한 테스트는 꼼꼼하게 작성해주는 연습을 해주자.
'JAVA > JAVA | Spring 학습기록' 카테고리의 다른 글
[Spring] log4j2를 활용한 로깅 전략을 다룬 yml 파일을 생성하자 (2) | 2022.08.21 |
---|---|
[JAVA] IoC, DI, DIP (0) | 2022.08.09 |
[Spring] 테스트에서 test 스키마가 아닌 main의 스키마를 의존한다면? _ application.yml 설정 (2) | 2022.06.09 |
[ERROR] Required request body is missing 해결 (0) | 2022.05.09 |
[ERROR] Request method 'GET' not supported 해결 (0) | 2022.05.09 |