JWT 검증하기 전에 parsing 해보자

 

문제

웹 서비스의 단일 키값으로 서명하는 방식은 하나의 키 유출로 모든 인증 프로세스가 유효하지 않게 된다.

이 문제를 개선시키기 위해, 전달할 토큰을 생성하면서 유저에게 고유한 키값을 할당한다.

 

구현

일반적으로 JWS를 검증하기 전에는 Jwts.parser() 메서드를 호출할 수 없으므로 저장한 key id 값을 추출할 수 없다.

jjwt에서는 다음과 같은 상황에서 key locator를 권장하고 있다.

If you need to support dynamic key lookup when encountering JWTs, you'll need to implement the Locator<Key> interface and specify an instance on the JwtParserBuilder via the keyLocator method.

 

먼저 LocatorAdapter를 확장하는 CustomKeyLocator를 생성한다.

@RequiredArgsConstructor
public class CustomKeyLocator extends LocatorAdapter<Key> {

    private final JwtService jwtService;

    @Override
    public Key locate(ProtectedHeader header) {
        String keyId = header.getKeyId();
        return lookupKey(keyId);
    }

    private SecretKey lookupKey(String keyId) {
        Jwt jwt = jwtService.loadJwtById(Long.parseLong(keyId));
        return getJwtKeyFromSecret(jwt.getJwtSecret());
    }

    private SecretKey getJwtKeyFromSecret(String jwtSecret) {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(jwtSecret));
    }
}

 

토큰을 생성할 때, 검증하기 전에 찾고 싶은 값을

다음처럼 .header().keyId({keyId}).and() 에 저장한다.

public String generateToken(Long userId, String role, Duration expiredAt) {
        Date now = new Date();

        Jwt jwt = jwtService.createJwt(userId);
        String jwtSecret = jwt.getJwtSecret();
        return Jwts.builder()
                .issuer(ISSUER)
                .header().keyId(String.valueOf(jwt.getId())).and()
                .subject(String.valueOf(userId))
                .claim("role", role)
                .issuedAt(now)
                .expiration(new Date(now.getTime() + expiredAt.toMillis()))
                .signWith(getJwtKeyFromSecret(jwtSecret))
                .compact();
    }

 

정상적으로 동작하는 것을 확인하기 위해 테스트 코드를 작성한다.

@Transactional
@SpringBootTest
class JwtProviderTest {

    @Autowired
    JwtProvider jwtProvider;
    @Autowired
    JwtService jwtService;
    @Autowired
    UserService userService;

    @DisplayName("사용자의 고유한 access token 생성한다. 생성된 토큰에는 jwt id, sub(userId), 만료기간을 포함한다.")
    @Test
    void generateToken() throws Exception {
        //given
        String email = "test@test.com";
        String name = "test";
        User user = userService.signUp(email, name);

        //when
        String token = jwtProvider.generateToken(user.getId(), user.getRole().toString(), ACCESS_TOKEN_DURATION);

        //then
        Claims claims = jwtProvider.loadPayloadAndValidateToken(token);
        Long userIdFromToken = Long.parseLong(claims.getSubject());
        
        assertThat(userIdFromToken).isEqualTo(user.getId());
        assertThat(claims.get("role")).isEqualTo(user.getRole().toString());
    }

    @DisplayName("발급된 토큰으로 사용자가 요청하면 토큰을 검증할 수 있다.")
    @Test
    void validateToken() throws Exception {
        //given
        String email = "test@test.com";
        String name = "test";
        User user = userService.signUp(email, name);
        String token = jwtProvider.generateToken(user.getId(), user.getRole().toString(), ACCESS_TOKEN_DURATION);

        //when //then
        assertThatCode(() -> jwtProvider.loadPayloadAndValidateToken(token)).doesNotThrowAnyException();
    }

}

 

참고 이슈

https://github.com/jwtk/jjwt/issues/67

 

Ability to inspect body of signed JWT · Issue #67 · jwtk/jjwt

When attempting to read the body of a signed jwt without setting a key, an IllegalArgumentException is thrown. For example: Jwts.parser().parseClaimsJws(someJwtString).getBody().get("iss"). This ma...

github.com

 

위 이슈를 요약하면

jwt 검증하기 전에 토큰에 저장된 속성값을 사용하고 싶다.

 

위 이슈의 답변을 기반으로 구현했으며, deprecated 인터페이스 말고 깃허브 메인 리드미에 권장하는 방법을 사용했다.