[Security] JJWT 적용....
결론적으로 이번 프로젝트에서는 JWS만 사용하여서 관리할 것 같다. 무결성을 보장하는 정도로도 충분할 것 같으며, 애초에 userId, 이름 정도만 claim에 들어갈 것이기 때문에 연산, 키 관리 시의 연산량 측면을 고려해봤을 때 JWS로도 충분하다고 생각했다.
현재 공식 페이지에서 제공하고 있는 종속성은 다음과 같다.
또한 다음과 같이 runtime 종속성으로 부여한 이유를 설명하고 있다.
<aside> 💡
JJWT는 애플리케이션에서 사용하도록 명시적으로 설계된 API에만 의존하도록 설계되었으며, 경고 없이 변경될 수 있는 기타 모든 내부 구현 세부 사항은 런타임 전용 종속성으로 분류됩니다. 안정적인 JJWT 사용과 지속적인 업그레이드를 위해서는 이 점이 매우 중요합니다.
</aside>
dependencies {
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' // or 'io.jsonwebtoken:jjwt-gson:0.12.6' for gson
}
JWT 생성
- Jwts.builder 를 사용하여 JwtBuilder 인스턴스를 만든다.
- header 에 매개변수를 선택적으로 세팅한다.
- payload에 content 나 claims를 설정하기 위해 builder methods를 부른다.
- signwith 나 encryptWith 를 선택적으로 불러서, JWT에 digitally sign(서명)을 추가하거나 암호화를 진행할 수 있다.
- compact() 를 호출하여 JWT 문자열을 생성한다.
- 주의사항:
- content 혹은 claims 둘 중 하나만 사용 가능하다.
- siginwith 혹은 encrypt 둘 중 하나만 사용 가능하다.
String jwt = Jwts.builder() // (1)
.header() // (2) optional
.keyId("aKeyId")
.and()
.subject("Bob") // (3) JSON Claims, or
//.content(aByteArray, "text/plain") // any byte[] content, with media type
.signWith(signingKey) // (4) if signing, or
//.encryptWith(key, keyAlg, encryptionAlg) // if encrypting
.compact(); // (5)
JWT 헤더
JWT 헤더에 속성들을 추가하고 싶을 때, 추천되는 2가지 방법이 있다.
- .add로 추가하다가 .and()로 헤더 추가를 끝내고 돌아가기
String jwt = Jwts.builder()
.header() // <----
.keyId("aKeyId")
.x509Url(aUri)
.add("someName", anyValue)
.add(mapValues)
// ... etc ...
.and() // go back to the JwtBuilder
.subject("Joe") // resume JwtBuilder calls...
// ... etc ...
.compact();
- 독립형 빌더를 사용하여 추가하기
- 재사용성 측면에서는 좋아보인다고 생각한다.
// perhaps somewhere in application configuration:
Header commonHeaders = Jwts.header()
.issuer("My Company")
// ... etc ...
.build();
// --------------------------------
// somewhere else during actual Jwt construction:
String jwt = Jwts.builder()
.header()
.add(commonHeaders) // <----
.add("specificHeader", specificValue) // jwt-specific headers...
.and()
.subject("whatever")
// ... etc ...
.compact();
Payload
Arbitrary Content
- JWT payload에 임의의 바이트 배열을 넣을 때,,,(권장되는 방식)
byte[] content = "Hello World".getBytes(StandardCharsets.UTF_8);
String jwt = Jwts.builder()
.content(content, "text/plain") // <---
// ... etc ...
.build();
- content()안 의 첫 번째 인수 content는 JWT payload로 설정할 실제 바이트 콘텐츠이다.
- 두 번째 인수는 IANA 미디어 유형의 문자열 식별자이다.
- 두 번째 인수는 JwtBuilder에 의하여 ctyp(Content Type) header가 설정이 되고, JWT가 추천하는 압축 포맷으로 변경된다.
이것이 왜 권장되냐면, cty에 설정되지 않으면 외부 정보를 통해 알아야 하는 불편함과 포맷이 바뀔 때 계속 코드를 바꿔줘야 한다. 웬만하면 content(byte[]) 방식 쓰지 말랍니다.
JWT Claims
Claims로 보낼 때 Claims JSON Object 방식이고, type-safe builder 메소드를 사용한 클레임 생성을 지원한다.
JwtBuilder에 사용할 수 있는 표준 클레임이름은 다음과 같다.
issuer: iss(발급자) 클레임을 설정합니다.
subject: sub(주제) 클레임을 설정합니다.
audience: aud(대상) 클레임을 설정합니다.
expiration: exp(만료 시간) 클레임을 설정합니다.
notBefore: nbf(Not Before) 클레임을 설정합니다.
issuedAt: iat(발급 시점) 청구를 설정합니다.
id: jti(JWT ID) 클레임을 설정합니다.
사용 예로는
String jws = Jwts.builder()
.issuer("me")
.subject("Bob")
.audience().add("you").and()
.expiration(expiration) //a java.util.Date
.notBefore(notBefore) //a java.util.Date
.issuedAt(new Date()) // for example, now
.id(UUID.randomUUID().toString()) //just an example id
/// ... etc ...
사용자 정의 claim은 다음과 같이 쓰면 된다.
String jws = Jwts.builder()
.claim("hello", "world")
// ... etc ...
한번에 여러가지 claim을 쓰고 싶을 때..
Map<String,?> claims = getMyClaimsMap(); //implement me
String jws = Jwts.builder()
.claims(claims)
// ... etc ...
JWT 읽기
다음과 같은 순서로 읽으면 된다.
- Jwts.parser() 를 사용하여 JwtParserBuilder 인스턴스를 생성한다.
- 선택적으로 verifyWith나 decryptWith method를 사용하여 서명, 암호화된 JWT를 parsing할 수 있다.
- build() 메소드 호출을 통해 JwtParserBuilder가 생성되고, thread-safe한 JwtParser 를 반환하게 한다.
- parse* 메소드 중 하나를 압축된 JWT 문자열과 같이 호출하여 parsing한다.
- 예외 처리를 대비한다.
Jwt<?,?> jwt;
try {
jwt = Jwts.parser() // (1)
.keyLocator(keyLocator) // (2) dynamically locate signing or encryption keys
//.verifyWith(key) // or a constant key used to verify all signed JWTs
//.decryptWith(key) // or a constant key used to decrypt all encrypted JWTs
.build() // (3)
.parse(compact); // (4) or parseSignedClaims, parseEncryptedClaims, parseSignedContent, etc
// we can safely trust the JWT
catch (JwtException ex) { // (5)
// we *cannot* use the JWT as intended by its creator
}
JWS를 복호화 시
Jwts.parser()
.verifyWith(secretKey) // <----
.build()
.parseSignedClaims(jwsString);
JWE를 복호화 시
Jwts.parser()
.decryptWith(secretKey) // <---- or a Password from Keys.password(charArray)
.build()
.parseEncryptedClaims(jweString);
JWS 만들기
payload를 설정 후에 비밀키를 설정한다.
String jws = Jwts.builder() // (1)
.subject("Bob") // (2)
.signWith(key) // (3) <---
.compact(); // (4)
JWE
- 의도된 JWT 수신자 외에는 누구도 JWT payload를 볼 수 없음을 보장한다(기밀성).
- JWT가 생성된 후 누구도 조작하거나 변경하지 못했음을 보장한다(무결성이 유지됨).
JWE는 Authenticated encryption 알고리즘을 통해 둘 다 유지할 수 있다고 한다. ****
2가지 단계를 통해서 생성이 된다.
- algorithmKey(secretKey)를 사용하여 payload를 암호화하는 데 실제로 사용될 키를 생성한다. JWT 사양에서는 이 결과를 ’콘텐츠 암호화 키(Content Encryption Key)’라고 부른다.
- 생성된 콘텐츠 암호화 키를 인증된 암호화(Authenticated Encryption) 알고리즘에 직접 사용하여 payload를 암호화한다.
이렇게 암호화하는 이유로는, 대칭 키 암호화 알고리즘은 매우 빠르기 때문에 모든 http 요청을 처리해야 하는 production 환경에서 채택하게 된 것이다.
대표적으로 들고 있는 예로는 다음과 같다.
<aside> 💡
이러한 이유 외에도, 하나의 보안 알고리즘을 사용하여 다른 (매우 빠른) 보안 알고리즘에 사용되는 키를 생성하거나 암호화하는 것은 훨씬 더 많은 보안 알고리즘을 통해 보안을 강화하는 동시에 매우 빠르고 안전한 출력을 제공하는 훌륭한 방법임이 입증되었습니다. TLS(https 암호화용)의 작동 원리는 바로 이것입니다. 두 당사자는 RSA 또는 타원 곡선과 같은 더 복잡한 암호화 방식을 사용하여 작고 빠른 암호화 키를 협상할 수 있습니다. 이 빠른 암호화 키는 'TLS 핸드셰이크' 과정에서 생성되며 TLS '세션 키'라고 합니다.
</aside>
JWE 만들기
String jwe = Jwts.builder() // (1)
.subject("Bob") // (2)
.encryptWith(key, keyAlgorithm, encryptionAlgorithm) // (3)
.compact(); // (4)