JJWT 라이브러리 선택 이유 및 간단한 소개
기술의 선택에는 이유가 있어야 한다!
jwt.io 홈페이지의 라이브러리 중 java를 지원하는 목록을 살펴봤습니다.
jjwt가 가장 많은 암호화 알고리즘과 편의메서드를 제공하는 것으로 확인됩니다.
비슷한 수준의 기능을 제공하는 다른 라이브러리도 있지만,
많은 사람들이 이미 선택하고 사용하여 레퍼런스가 많고 커뮤니티가 형성되어 관리되고 있다는 점도 매력 포인트입니다.
RFC 명세를 완전히 충족했고, 100%의 테스트 커버리지를 통해 안정성을 제공한다는 점도 매력적입니다.
추가적으로 명세를 넘어서는 편의 기능도 제공하는데요, 특정 클레임이 반드시 존재해야 한다 라는 조건 등도 설정할 수 있고,
Body 압축 기능도 제공합니다. 또한 특정 알고리즘을 이용한 비밀키 생성 기능도 제공해주고요.
그래서 Java로 JWT를 다룰 때 현재로선 jjwt가 가장 좋은 선택지가 아닌가 생각합니다.
깃허브에 들어가보면, 아주 친절한 README와 잘 형성된 커뮤니티를 엿볼 수 있습니다.
이러한 커뮤니티가 지속 가능성에 대한 기대와 신뢰를 구축하는데 큰 영향을 미치는 것 같습니다.
다음 사람이 이 라이브러리를 선택하게 만드는 주요한 요소 중 하나인 것 같아요.
이런 관점에서 보면 오픈 소스에 기여한다는 것은 IT생태계에 정말 큰 기여를 하는 거라는 생각이 듭니다.
감사합니다 선배님들..
mavenrepository에서는 2018년의 0.9.1 버전이 가장 최신 버전으로 나오는데요,
공식 깃허브에서는 2022년 5월 1일 merge된 0.11.5 버전이 최신 버전입니다.
라이브러리 선택에 있어 공식 깃허브에 들어가서 버전을 확인하고 문서를 확인하고,
핵심 기능일 경우 일부 내부 코드까지 살펴보는 것은 아주 중요한 작업으로 생각됩니다.
핵심 기능 3가지 - 키 생성, 토큰 생성, 토큰 읽기
키를 만들고, 토큰을 만들고, 토큰을 읽는다!
서버에서만 가지고 있는 키가 하나 있어야 합니다. 수평 확장 시, 모든 서버가 같은 키를 가지고 있어야 합니다.
(이 키를 생성하는 기능도 jjwt에서 제공합니다. 임의의 문자열이 더 안전하기 때문입니다.)
추후 토큰을 만들때 이 키를 이용해 암호화를 적용하게 됩니다.
또한 토큰을 사용자가 전해왔을 때, 토큰의 유효성 검사, 토큰의 클레임 추출 시에도 같은 키가 사용됩니다.
위에 언급된 3가지가 결국 토큰 방식의 핵심 기능입니다.
3가지 기능 을 자세히 들여다보면 JWT명세를 충족시키기 위해 많은 내부 동작들이 존재하지만,
이번 포스팅에서는 간단히 jjwt를 이용해 위 세 가지 기능을 어떻게 쉽게 다룰 수 있게 해주는지 알아보겠습니다.
키 생성하기
보다 안전한 키를 생성하는 기능을 제공합니다!
private final SecretKey secretKey;
private final long validityInMilliseconds;
public JwtTokenProvider(
@Value("${security.jwt.token.secret-key}") String secretKey,
@Value("${security.jwt.token.expire-length}") long validityInMilliseconds
) {
this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.validityInMilliseconds = validityInMilliseconds;
}
HS256("HS256", "HMAC using SHA-256", "HMAC", "HmacSHA256", true, 256, 256, "1.2.840.113549.2.9"),
HS384("HS384", "HMAC using SHA-384", "HMAC", "HmacSHA384", true, 384, 384, "1.2.840.113549.2.10"),
HS512("HS512", "HMAC using SHA-512", "HMAC", "HmacSHA512", true, 512, 512, "1.2.840.113549.2.11"),
private static final List<SignatureAlgorithm> PREFERRED_HMAC_ALGS = Collections.unmodifiableList(Arrays.asList(
SignatureAlgorithm.HS512, SignatureAlgorithm.HS384, SignatureAlgorithm.HS256));
public static SecretKey hmacShaKeyFor(byte[] bytes) throws WeakKeyException {
for (SignatureAlgorithm alg : PREFERRED_HMAC_ALGS) {
if (bitLength >= alg.getMinKeyLength()) {
return new SecretKeySpec(bytes, alg.getJcaName());
}
}
// throw new WeakKeyException() ...
}
애플리케이션 기동 시, JwtTokenProvider 인스턴스가 생성되면서 SecretKey 인스턴스를 할당하는 과정입니다.
Keys.hmacShaKeyFor 메서드에는 환경설정에 있는 비밀키 문자열을 바이트배열로 전달하며 SecretKey 인스턴스를 생성합니다.
내부에서는 HS512, HS384, HS256 순서대로 순회하면서,
최소 길이를 만족하는지 확인하는 방법으로 최소 길이 기준이 높은 것을 우선하여 암호화 알고리즘이 선택됩니다.
이때 중요한 것은 환경설정에 있는 security.jwt.token.secret-key 에 할당된 비밀키 문자열입니다.
이를 사람이 만들지 않고 임의의 값으로 자동으로 생성해주는 기능이 제공됩니다.
아래의 코드를 이용해 키를 생성하고, 생성된 키를 환경설정에 넣어 사용하면 됩니다.
내부적으로 선택된 알고리즘의 최소길이 기준을 만족하는 임의의 값을 생성합니다.
final SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
final String secretString = Encoders.BASE64.encode(key.getEncoded());
데모데이 때 최초 제공되었던 비밀키를 그대로 사용한다면,
제가 배포한 서비스가 털리는 광경을 실시간으로 보게 될 것 같아서;
팀원들과 논의해 비밀키를 변경하는 걸 고려해봐야겠습니다.
토큰 생성하기
인증에 성공한 사용자에게 토큰을 만들어줍시다!
// Example Code on official jjwt github Readme
String jws = Jwts.builder()
.setIssuer("me")
.setSubject("Bob")
.setAudience("you")
.setExpiration(expiration) //a java.util.Date
.setNotBefore(notBefore) //a java.util.Date
.setIssuedAt(new Date()) // for example, now
.setId(UUID.randomUUID()) //just an example id
// Example Code using on mission
@Override
public String createToken(Map<String, Object> claims) {
final Date now = new Date();
final Date validity = new Date(now.getTime() + validityInMilliseconds);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(secretKey)
.compact();
}
자주 사용되는 Claim을 payload에 쉽게 할당할 수 있도록 편의 메서드를 fluent API로 제공합니다.
현재 미션 진행중인 코드에서는 iat, exp, id 세 가지 정보만 payload에 담기로 하였는데요,
추후 확장성을 고려해 TokenProvider 인터페이스의 createToken의 매개변수를 Map<String, Object>로 구현해두었습니다.
따라서 추후 payload에 담기는 정보가 늘어난다 하더라도 호출하는 곳에서 Map으로 전달하기만 하면,
TokenProvider 내부 구현은 변경되지 않을 수 있게 되었습니다.
메서드를 읽기만 해도 어떤 작업이 진행되는지 쉽게 이해되는 것 같아요.
SecretKey와 payload에 담을 정보만 준비된다면, 이렇게 간단하게 토큰을 생성할 수 있게 해줍니다.
최초 제공된 코드는 deprecated 된 메서드들을 일부 사용하고 있었는데요,
이를 대체할 메서드들을 찾는 과정에서 시간이 좀 소요되었었어요.
처음부터 공식문서의 예시코드를 봤다면 아주 수월했을텐데 하는 생각이 들었습니다.
토큰 읽기
사용자가 토큰을 가져왔네요! 정상적인 토큰인지, 어떤 정보를 담고 있는지 확인해볼까요?
// Example from jjwt Readme
try {
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(compactJws);
//OK, we can trust this JWT
} catch (JwtException e) {
//don't trust the JWT!
}
// Example using on mission
public Map<String, Object> getPayload(String token) {
try {
final Claims body = extractBody(token);
return body.entrySet()
.stream()
.collect(Collectors.toMap(Entry::getKey, Entry::getValue));
} catch (JwtException | IllegalArgumentException e) {
throw new TokenInvalidException();
}
}
private Claims extractBody(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
}
무엇 하나 빼놓을 수 없는 기능들이지만, 아무래도 가장 중요한 부분은 토큰 읽기 기능일 겁니다.
여기에서 jjwt 라이브러리의 핵심 기능이 나오는데요,
payload를 파싱하는 과정에서 유효성 검증을 내부적으로 알아서 처리해준다는 것입니다.
즉, getBody()의 결과로 반환된 Claims 내부의 exp 등을 추가로 검증할 필요가 없다는 것이죠.
미션에서 사용한 코드는 토큰이 유효할 경우, payload내부의 모든 값을 Map<String, Object> 로 반환하도록 구현하였고,
이를 통해 추후 토큰 내부 구성이 달라지더라도 TokenProvider 내부 구현이 달라지지 않아도 되게 구현했습니다.
토큰 유효성 검증 조금 더 살펴보기
parseClaimsJws 내부에선 어떤 검증을 처리해주고 있을까요?
일반적으로 사용되는 토큰의 유효성 검증이 필요한 내역은 다음과 같습니다.
1. 토큰의 형식이 유효한지
2. 토큰이 변조되지 않았는지
3. 토큰의 만료시간이 현재시간보다 이후인지
(구현하기에 따라 추가될 수도 있습니다.)
그리고 이 내용은 메서드 시그니처만 봐도 모두 검증되고 있을 거라고 짐작할 수 있습니다.
@Override
public <T> T parse(String compact, JwtHandler<T> handler)
throws ExpiredJwtException, MalformedJwtException, SignatureException {
// ...
}
던지는 예외들의 이름만 봐도 어떤 검증을 하는지 확인할 수 있습니다.
SignatureException은 서버에서 가지고 있는 키로 복호화가 가능한지,
즉 서버에서 발행한 토큰이 맞는지 확인하는 과정이자 변조되었는지 여부를 확인하는 과정인데요,
내부적으로 MessageDigest 의 isEqual 메서드를 이용하고 있습니다.
ExpiredException은 만료시간이 현재시간보다 이전인 경우 발생합니다.
즉 내부에서 검증을 다 해주고 있습니다.
추가적으로 메서드 시그니처에서 드러나진 않았으나 nbf 클레임도 검증해주고 있습니다.
특정 시간 이후부터 유효한 토큰을 발급해서 미리 배부한 경우 사용됩니다.
유효시작시간 보다 현재 시간이 더 이전인 경우, PrematureJwtException이 던져집니다.
즉, 대부분의 필요한 유효성 검증을 내부적으로 parse하는 과정에서 다 처리해준다는 것입니다.
커스텀한 규칙을 추가한 경우에만 parse한 claim에서 추가로 검증하면 될 것 같습니다.
payload 내 일부를 객체로 파싱하기
이부분은 아직..
{
"issuer": "https://example.com/issuer",
"user": {
"firstName": "Jill",
"lastName": "Coder"
}
}
Jwts.parserBuilder()
.deserializeJsonWith(new JacksonDeserializer(Maps.of("user", User.class).build())) // <-----
.build()
.parseClaimsJwt(aJwtString)
.getBody()
.get("user", User.class) // <-----
클레임 중 일부를 객체로 바로 파싱하는 기능도 제공됩니다.
body 전체를 가져온 이후, body 내 특정 필드를 특정 객체로 파싱하는 것입니다.
대신 parserBuilder를 빌드하기 이전에 역직렬화에 사용될 설정을 전달해야 합니다.
현재 "id" 필드에 직접 값으로 id값만 가지고 있는데요,
이를 LoginMember에 직접 할당하기 위해 여러가지 방법을 찾아봤지만 아직 구현하지 못했습니다.
만약 토큰의 구성이 "user": { "id": id} 와 같은 구성이었다면 간단히 처리되었을 것 같은데요,
이부분은 팀과 협의되지 않은 부분이라 진행되기 어려울 것 같습니다.
다만 이렇게 처리할 경우, 사용자에 관련된 권한을 한 곳에 몰아둘 수 있고, 추후 권한이 추가되더라도 이 안에 추가될 수 있으며,
객체로 다루기가 쉽다는 점에서 장점이 확실히 있을 것 같습니다.
요약
훌륭하군요!
JWT를 잘 모르는 사람도 키를 만들고, 토큰을 만들고, 토큰을 읽어들여 사용하는 데에 전혀 무리가 없을 정도로
Readme 파일이 친절했습니다.
추가로 질문을 어떻게 해야할지, 이슈는 어떻게 사용하는지 등에 대해서도 체계가 잘 잡혀있었습니다.
최근까지 계속해서 유지보수가 되고 있다는 점에서 선택의 망설임을 줄여주는 매력을 느꼈습니다.
세션 방식이 토큰 방식에 비해 훨씬 좋은 것 같다고 많이 느껴지고 있었던 요즘인데요,
리뷰어의 말씀에 따라 왜 이러한 기술이 필요했을지 근원에 대해 생각해보다가 한 가지 떠오른 지점이 있었습니다.
외부 사용자까지 모두 세션으로 관리할 수가 없겠다는 지점이었습니다.
즉, 깃허브에 직접 로그인해서 깃허브 서비스를 사용하는 경우에 대해서는
세션으로 관리하는 장점을 충분히 누릴 수 있을 것입니다.
그러나 깃허브 로그인을 OAuth로 이용하여 사용자를 식별하기만 하는
외부 서비스의 로그인 정보까지도 깃허브에서 모두 세션으로 관리하는 것은 상당히 비효율적일 것입니다.
이런 측면에서 공식 문서에서 권한을 부여하는 곳과 자원을 제공하는 곳을 분리해서 표현했구나 하는 생각이 들었습니다.
외부서비스는 깃허브의 권한 부여소에서 사용자가 크레덴셜을 주고 토큰을 받아오게 하고,
이 토큰으로 깃허브의 자원 저장소에 접근하는 것입니다.
요새 인증과 인가 이슈에 대해 고민도 많고 학습할 양도 많은데
그러다보니 잘 정리가 안되네요.
아무튼 쪼개서 조금씩 해보겠습니다!
그래서 오늘은 jjwt 라이브러리 살짝 훑어봤습니다!
'우아한테크코스 4기' 카테고리의 다른 글
💿 Response Header 와 브라우저를 이용한 캐싱 (0) | 2022.06.19 |
---|---|
📮 POST vs PUT (Collection, Store) (0) | 2022.06.19 |
🪪 출입증 (8) | 2022.06.04 |
🪙JWT vs 🍪Session, 그리고 실제 사례 살짝 분석 (찜꽁, 프롤로그, LMS) (8) | 2022.06.01 |
스프링 통합 테스트에 사용되는 도구와 설정들(Sql, Transactional, JdbcTestUtils) (6) | 2022.05.24 |