kellis 2020. 10. 12. 10:10

단순히 Spring Security를 이용하여 A/A를 구성하게 되면 Session에 로그인된 계정을 저장해놓고, 요청이 올 때마다 Session을 조회하여 로그인 여부를 확인합니다. 그러면 사용자가 많은 웹 애플리케이션의 경우에는 Session을 저장하기 위한 저장소와 조회 행위 모두 비용을 증가시킵니다.  

 

이에 대한 대안으로 JWT가 제안되었습니다. JWT는 JSON 객체로 서버-클라이언트 간에 안전하게 정보를 전송하기 위한 방법을 정의한 공개 표준(RFC 7519)입니다. Session에 저장했던 데이터들을 각각의 클라이언트가 JWT형태로 가지고 있자는 취지입니다. 

 

이 포스트에서는 JWT의 형태를 알아보고, SpringBoot에서 JWT 발급 및 복호화를 해보겠습니다. 

 

[1] JWT 형태

JWT는 3가지 정보(Header, Payload, Signature)의 조합으로 구성되어 있습니다. 이 정보들이 각각 Base64로 인코딩 되어 구분자(.)를 통해 하나의 문자열로 합쳐집니다. 

아래와 같은 인코딩 된 문자열을 "예제 JWT 토큰"이라고 칭하겠습니다.

1) Header는 두 가지 정보를 담고 있습니다. 

alg는 최종적으로 만들어지는 JWT 토큰을 검증할 때 사용되는 해싱 알고리즘을 의미합니다. 이 포스트에서는 HMAC SHA256을 사용하겠습니다. 그리고 이 데이터를 Base64로 인코딩하면 예제 JWT 토큰의 빨간색 문자열로 변환됩니다. 

 

2) Payload는 Registered Claims, Public Claims, Private Claims을 조합해서 구성할 수 있습니다.

Registered Claims는 토큰에 대한 정보로 다음과 같은 내용을 포함합니다.

-발급자

-제목

-대상자

-만료 시각

-활성화 날짜

-발급 시각

 

Public Claims는 사용자 정의 Claim이지만 충돌을 방지하기 위해 key가 UUID이거나 URI로 정의해야 합니다. 

Private Claims도 사용자 정의 Claim인데, 이 Claim은 통신하고 있는 서버-클라이언트가 공유하기 위한 데이터입니다.

이 글에서는 Registed Claims 중 발급 시각과 만료 시각에 대한 정보와 Private Claims를 Payload에 담았습니다.

이 데이터를 Base64로 인코딩하면 예제 JWT 토큰의 보라색 문자열로 변환됩니다. 

 

3) Signature는 이 토큰이 유효한지, 위변조 되지 않았는지를 판단하기 위한 슈도 코드입니다. Signature는 Header의 인코딩 값과 Payload의 인코딩 값을 합친 후, 서버만이 알고 있는 비밀키로 해쉬를 하여 생성합니다.  이때 사용되는 해싱 방법은 Header에서 정의한 알고리즘입니다. 해싱한 결괏값이 예제 JWT 토큰의 파란색 문자열입니다. 

 

[2] Java에서 JWT 생성 및 복호화. 

1) java를 지원하는 JWT Library(Java-JWT, Nimbus JOSE-JWT, JJWT)가 여러 가지 있습니다. 이 예제에서는 JJWT를 이용하기로 했습니다.

Jwt library pom.xml에 추가합니다.

<dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
</dependency>

 

2) JWT 생성

@Service
public class JwtService{
    private static final String ENCRYPT_STRING =  "pretty";
    private static final Logger LOGGER  = LoggerFactory.getLogger(JwtService.class);
    private static final String DATA_KEY = "user";
 
    public String createLoginToken(User user) {
        long curTime = System.currentTimeMillis();
        return  Jwts.builder()
                 .setHeaderParam("typ", "JWT")
                 .setExpiration(new Date(curTime + 3600000))
                 .setIssuedAt(new Date(curTime))
                 .claim(DATA_KEY, user)
                 .signWith(SignatureAlgorithm.HS256, this.generateKey())
                 .compact();
    }
 
    private byte[] generateKey(){
        byte[] key = null;
        try {
            key = ENCRYPT_STRING.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
            LOGGER.error("Making secret Key Error :: ", e);
        }
 
        return key;
    }
 
    //..생략..
 
}
  • 10라인 : jjwt library의 Jwts로부터 jwt을 생성할 수 있습니다. 코드에서 볼 수 있듯 builder pattern임을 알 수 있습니다.
  • 11라인 : setHeaderParam method를 통해 JWT Header가 지닐 정보들을 담습니다. 코드에는 typ만 설정했는데,  alg의 경우는 default값이 HS256이기에 굳이 설정하지 않았습니다. (typ는 default값이 없으므로 미설정시 오류가 발생합니다.
  • 12~13라인 : 발급 시각과 만료 시각에 대한 정보를 Payload에 담기 위해 setIssuedAt method와 setExpiration method를 이용했습니다. 
  • 14라인 : Payload에 Private Claims를 담기 위해 claim method를 이용합니다.
  • 15라인 : 복호화할 때 사용하는 Signature를 설정합니다. 위에서 언급했다시피 Signature는 Header의 인코딩 값과 Payload의 인코딩 값을 합친 후, 서버만이 알고 있는 비밀키로 해쉬를 하여 생성합니다. signWith api는 해싱할 알고리즘과 비밀키를 필요로 합니다. this.generateKey()가 비밀키를 반환합니다. 

 

3) JWT 복호화

@Service
public class JwtService{
    private static final String ENCRYPT_STRING =  "pretty";
    private static final Logger LOGGER  = LoggerFactory.getLogger(JwtService.class);
 
    @Autowired
    private ObjectMapper objectMapper;
 
    //...생략...
 
    private byte[] generateKey(){
        byte[] key = null;
        try {
            key = ENCRYPT_STRING.getBytes("UTF-8");
        } catch (UnsupportedEncodingException e) {
            LOGGER.error("Making secret Key Error :: ", e);
        }
 
        return key;
    }
 
    public User getUser(String jwt) {
        Jws<Claims> claims = null;
 
        try {
            claims = Jwts.parser()
                         .setSigningKey(this.generateKey())
                         .parseClaimsJws(jwt);
        } catch (Exception e) {
            LOGGER.error(e.getMessage(), e);
            throw new JWTException("decodeing failed");
        }
 
        return objectMapper.convertValue(claims.getBody().get(DATA_KEY), User.class);
    }
}
  • 26~28라인 : 비밀키를 이용해 현재 복호화하려는 jwt가 유효한지, 위변조 되지 않았는지 판단합니다. 이 비밀키는 서버에만 존재해야 하며 유출되어선 안됩니다. 여기서는 "pretty"라는 짧은 단어로 설정했지만, 쉽게 유추하지 못하는 긴 문자열로 설정하는 것이 바람직합니다. 
  • 34라인 : claims.getBody().get(DATA_KEY)이 반환하는 타입은 LinkedHashMap입니다. 이를 User type으로 변환하기 위해 ObjectMapper를 이용했습니다. 

 

이 소스로 다음 몇 가지 테스트를 진행했을 때 생기는 오류.

- JWT를 생성할 때와 복호화할 때의 비밀키를 다르게 설정: SignatureException 발생

- 위조한 JWT에 대해 복호화를 시도:  MalformedJwtException 발생

- 만료기간이 지난 JWT에 대해 복호화를 시도:  ExpiredJwtException 발생

 

 

 

[references]

JSON Web Token (JWT)

jwt.io