Back-end/SpringBoot

[Security] JJWT 적용....

backend 개발자 지망생 2025. 5. 24. 23:59

결론적으로 이번 프로젝트에서는 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 생성

  1. Jwts.builder 를 사용하여 JwtBuilder 인스턴스를 만든다.
  2. header 에 매개변수를 선택적으로 세팅한다.
  3. payload에 content 나 claims를 설정하기 위해 builder methods를 부른다.
  4. signwith 나 encryptWith 를 선택적으로 불러서, JWT에 digitally sign(서명)을 추가하거나 암호화를 진행할 수 있다.
  5. compact() 를 호출하여 JWT 문자열을 생성한다.
  • 주의사항:
    1. content 혹은 claims 둘 중 하나만 사용 가능하다.
    2. 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가지 방법이 있다.

  1. .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();
  1. 독립형 빌더를 사용하여 추가하기
    1. 재사용성 측면에서는 좋아보인다고 생각한다.
// 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();
  1. content()안 의 첫 번째 인수 content는 JWT payload로 설정할 실제 바이트 콘텐츠이다.
  2. 두 번째 인수는 IANA 미디어 유형의 문자열 식별자이다.
  3. 두 번째 인수는 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 읽기

다음과 같은 순서로 읽으면 된다.

  1. Jwts.parser() 를 사용하여 JwtParserBuilder 인스턴스를 생성한다.
  2. 선택적으로 verifyWith나 decryptWith method를 사용하여 서명, 암호화된 JWT를 parsing할 수 있다.
  3. build() 메소드 호출을 통해 JwtParserBuilder가 생성되고, thread-safe한 JwtParser 를 반환하게 한다.
  4. parse* 메소드 중 하나를 압축된 JWT 문자열과 같이 호출하여 parsing한다.
  5. 예외 처리를 대비한다.
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

  1. 의도된 JWT 수신자 외에는 누구도 JWT payload를 볼 수 없음을 보장한다(기밀성).
  2. JWT가 생성된 후 누구도 조작하거나 변경하지 못했음을 보장한다(무결성이 유지됨).

JWE는 Authenticated encryption 알고리즘을 통해 둘 다 유지할 수 있다고 한다. ****

2가지 단계를 통해서 생성이 된다.

  1. algorithmKey(secretKey)를 사용하여 payload를 암호화하는 데 실제로 사용될 키를 생성한다. JWT 사양에서는 이 결과를 ’콘텐츠 암호화 키(Content Encryption Key)’라고 부른다.
  2. 생성된 콘텐츠 암호화 키를 인증된 암호화(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)