개발블로그

JWT(Json Web Token)란? 본문

Spring

JWT(Json Web Token)란?

개발자수니 2019. 3. 13. 11:50

이전 포스트들(Authentication/Authorization 기능 구현(1), Authentication/Authorization 기능 구현(2)) 에서 Session을 이용하여 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) JWT과 관련된 다양한 API를 제공하는 jjwt library를 사용하기 위해 pom.xml에 추가합니다. 

1
2
3
4
5
<dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.1</version>
</dependency>
cs

 

2) JWT 생성

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@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;
    }
 
    //..생략..
}
cs

 

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 복호화

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@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);
    }
}
cs

 

26~28라인 : 비밀키를 이용해 현재 복호화 하려는 jwt가 유효한지, 위변조되지 않았는지 판단합니다. 이 비밀키는 서버에만 존재해야 하며 유출되어선 안됩니다. 여기서는 "pretty"라는 짧은 단어로 설정했지만, 쉽게 유추하지 못하는 긴 문자열로 설정하는 것이 바람직합니다. 

34라인 : claims.getBody().get(DATA_KEY)이 반환하는 타입은 LinkedHashMap입니다. 이를 User type으로 변환하기 위해 ObjectMapper를 이용했습니다. 

 

전체 소스는 Github에서 확인할 수 있습니다. 

 

이 소스로 다음 몇가지 테스트를 진행했습니다.

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

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

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

 

 

다음 포스트에서는 Session대신 JWT를 이용해 Angular-Springboot A/A기능을 구현해보겠습니다.

 

 

[참고 자료] 

https://velopert.com/2389

https://alwayspr.tistory.com/8

https://jwt.io/

 

 

 

Comments