확실히 Spring 관련된 기술을 사용하면서
프로그래밍을 하고 있다는 느낌이 들때가 많아서 좋다.

image

사용하려는 프레임워크나 기술에 대한
아키텍처 구조를 파악하고, 실제 어떻게 호출과 응답이 이루어지는지
확인하는 과정이 순탄치는 않지만… 조금씩 눈에 들어오니
확실히 재미의 가속도가 붙는 것 같다.


3일 동안 Spring Security에 대한 내용을 얼추 마무리했다.
오늘은 JWT에 대한 내용을 공부하는 시간이고
JWT를 사용해 어떻게 Spring Security와 연결을 지어가는지
2일에 걸쳐 공부해볼려고 한다.

JWT 기초

JWT란?

Json Web Token의 약자로
Json 포맷으로 사용자에 대한 속성을 저장하는 웹 토큰이다.

기존에는 세션기반 인증으로 서버에 유저정보를 담는 방식으로 인증을 진행했었는데
이렇게 되면 매번 요청을할때마다 DB를 살펴보아야하기 때문에
이러한 부담을 줄이기 위해 토큰기반 인증이 나왔다.

토큰은 유저 정보를 암호화한 상태로 담을 수 있고
암호화했기 때문에 클라이언트에 담을 수 있다.

토큰기반 인증의 장점?

  1. 무상태성, 확장성
    -> 서버는 클라이언트에 대한 정보를 저장할 필요가 없다.
    -> 토큰을 헤더에 추가함으로 인증절차 완료
  2. 안정성
    -> 암호화 한 토큰을 사용
  3. 어디서나 생성 가능
    -> 토큰을 생성하는 서버가 꼭 토큰을 만들지 않아도됨
  4. 권한 부여에 용이
    -> 토큰의 payload 안에 어떤 정보에 접근이 가능한지 정의


JWT 종류

JWT는 Access Token, Refresh Token
이 두가지 종류의 토큰을 사용자의 자격 증명에 이용한다.

Access Token : 보호된 정보들에 접근할 수 있는 권한을 부여할때 사용
Refresh Token : Access Token의 유효기간이 만료되면 Refresh Token을 이용해
새로운 Access Token을 발급 받는다. (다시 로그인 인증할 필요 X)

Access Token이 탈취당했을 경우
탈취한 사람이 해당 사용자인 것 처럼 서버에 여러가지 요청을 보낼 수 있다.
그렇기 때문에 탈취당하더라도 오랫동안 사용할 수 없도록
Refresh Token을 사용해 Access Token을 새로 발급을 받는다.

만약 Refresh Token까지 탈취를 당했을 경우
탈취한 사람이 해당 사용자에 큰 해를 입힐 수 있다.
그렇기 때문에 로그인 인증을 다시할 필요없는 편의 같은 기능보다
정보를 지키는 것이 더 중요한 웹 애플리케이션은 Refresh Token을
사용하지 않는 곳이 많다고 한다.
(즉, Access Token 유효기간이 만료되면 재인증을 해야함)


JWT 구조

image
출처 - Catsbi님 blog

JWT는 Header.Payload.Signature 구조로 이루어진다.

1). Header
-> 어떤 종류의 토큰인지
-> 어떤 알고리즘으로 암호화 하는지
-> Header의 JSON 객체를 base64 방식으로 인코딩한다.

{
  "alg": "HS256",
  "typ": "JWT"
}


2). Payload
-> 유저의 정보 (사용자의 이름 등)
-> 민감한 정보는 담지 않는 것이 좋다.
-> 기타 필요한 정보
-> Payload의 JSON 객체를 base64 방식으로 인코딩한다.

{
  "sub": "Information",
  "name": "LeeJaehyeok",
  "iat": 151623391
}


3). Signature
-> Header, Payload를 base64 인코딩한 값과 salt값의 조합으로 암호화된 값
-> 단방향 암호화를 수행한다.
-> 이렇게 암호화된 메세지는 토큰의 위변조 유무를 검증하는데 사용한다.

HMACSHA256(base64UrlEncode(header) + '.' + base64UrlEncode(payload), secret);


세션기반 자격증명 vs 토큰기반 자격 증명

✅ 세션기반 자격 증명

  • 인증된 사용자 정보를 서버 측 세션 저장소에서 관리한다.
  • 생성된 사용자 세션의 고유 ID인 세션 ID는 클라이언트의 쿠키에 저장된다.
    request 전송 시, 인증된 사용자인지를 증명하는 수단으로 사용한다.
  • 세션 ID만 클라이언트 쪽에서 사용해 상대적으로 네트워크 트랙픽을 적게 사용한다.
  • 서버 측에서 세션 정보를 관리하므로 보안성 측면에서 유리하다.
  • 서버의 확장성 면에서는 세션 불일치 문제가 발생할 가능성이 높다.
  • 세션 데이터가 많아질수록 서버으 부담이 가중된다.
  • SSR 박식의 애플리케이션에 적합한 방식이다.


✅ 토큰기반 자격 증명의 특징

  • 인증된 사용자 정브는 서버 측에서 별도의 관리를 하지 않는다.
  • 생성된 토큰을 Header에 포함시킨다. request 전승 시, 인증된 사용자인지 증명하는 수단으로 사용된다.
  • 토큰내에 인증된 사용자 정보 등을 포함하고 있어 세션에 비해 상대적으로 많은 네트워크 트래픽을 사용한다.
  • 서버 측에서 토큰을 관리하지 않으므로 보안성 측면에서 조금 더 불리하다.
  • 인증된 사용자 request의 상태를 유지할 필요가 없기 때문에 서버의 확장성면에서 유리하고
    세션 불일치 같은 문제가 발생하지 않는다.
  • 토큰에 포함되는 사용자 정보는 토큰의 특성상 암호화가 되지 않기때문에 공격자에게 토큰이 탈취될 경우
    사용자 정보를 그대로 제공하는셈이 되므로 민감한 정보는 토큰에 포함시키지 말아야한다.
  • 기본적으로 토큰이 만료되기 전까지는 토큰을 모효화 시킬 수 없다.
  • CSR 방식의 애플리케이션에 적합한 방식이다.

토큰기반 인증절차

아래의 사진은 토큰기반 인증절차에 대한 큰 흐름이다.

image

  1. 클라이언트에서 로그인 요청을 보냅니다.
  2. 아이디/비밀번호가 일치하는지 확인하고, 클라이언트에게 보낼 암호화된 토큰을 생성합니다.
  3. 토큰을 클라이언트에게 전송하면, 클라이언트는 토큰을 저장합니다.
    -> 저장하는 위치는 Local Storage, Session Storage, Cookie 등이 될 수 있다.
  4. 클라리언트가 Headers 또는 쿠키에 토큰을 담아 어떠한 요청을 합니다.
  5. 서버에서 토큰을 검증해 서버에서 발급한 토큰이 맞을 경우, 클라이언트의 요청을 처리한 후 응답을 보낸다.


JWT 토큰 만들어보기

Spring Security와 JWT를 같이 사용해보기전에 먼저
JWT 토큰을 만드는 방법을 코드로 작성해보자

1). 의존 라이브러리 추가

//jwt 의존 라이브러리
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly	'io.jsonwebtoken:jjwt-jackson:0.11.5'

우선 build.gradle에 jjwt 의존라이브러리를 추가해준다.

image

추가된 모습을 확인할 수 있다.


2). AccessToken 만들기
JWT 토큰을 생서해주는 클래스를 만들어보자.

public class JwtTokenizer {
    
   public String encodeBase64SecretKey(String secretKey) {
      String encode = Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
      return encode;
   }

   public String generateAccessToken(Map<String, Object> claims, String subject, Date expiration, String base64EncodedSecretKey) {

      Key key = getKeyFormBase64EncodedKey(base64EncodedSecretKey);

      return Jwts.builder()
              .setClaims(claims) // Custom Claims를 추가 (인증된 사용자와 관련된 정보)
              .setSubject(subject) // JWT에 대한 제목을 추가
              .setIssuedAt(Calendar.getInstance().getTime()) // JWT 발행 일자 설정
              .setExpiration(expiration) // JWT 만료일자 설정
              .signWith(key) // 서명을 위한 Key 객체를 넣어준다.
              .compact(); // JWT를 생성하고 직렬화해줌
   }
   
   ... 생략
   
   private Key getKeyFormBase64EncodedKey(String base64EncodedSecretKey){
      byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey); // 디코딩 진행
      Key key = (Key) Keys.hmacShaKeyFor(keyBytes); // HMAC 알고리즘을 적용한 Key 객체 생성
      return key;
   }
}

1). encodeBase64SecretKey(); 메서드
해당 메서드 부터 살펴보면, String 타입의 객체 secretKey라는 것을
매개변수로 받고있다. 받은 매개변수는 Base64 인코딩을 통해 나온 객체를 다시 리턴해준다.
즉, secretKey를 인코딩해주는 메서드이다.

2). generateAccessToken(); 메서드
AccessToken을 만들어주는 메서드이다.
우선 매개변수로 받는 객체는 총 4개가 된다.

Map<String, Object> claims
String subject
Date expiration
String base64EncodedSecretKey

claims 객체는 사용자의 관련된 정보가 Map에 담겨있다.
subject 객체는 해당 토큰의 제목이다.
expriation 객체 같은 경우에는 토큰의 만료 시간을 설정한다.
base64EncodedSecretKey 객체는 1)번에서 인코딩한 객체를 넣어주면된다.

3). getKeyFormBase64EncodedKey(); 메서드
인코딩으로 받은 객체를 다시 디코딩해주는 메서드이다.
HMAC_SHA 알고리즘을 적용해 Key객체로 만들어 리턴해준다.
해당 키는 2)번에서 Access Token을 만들대 사용된다.


이렇게 우선 AccessToken을 만들어주는 메서드를 구현했다.
해당 메서드를 테스트코드를 만들어 검증해보자.

@TestInstance(TestInstance.Lifecycle.PER_CLASS) // 테스트 인스턴스 생성단위가 클래스임
class JwtTokenizerTest {
   private static JwtTokenizer jwtTokenizer;
   private String secretKey;
   private String base64EncodedSecretKey;

   @BeforeAll
   public void init() {
      jwtTokenizer = new JwtTokenizer();
      secretKey = "LeeJaehyeok637637123231231231123"; // 디코딩값: TGVlSmFlaHllb2s2Mzc2MzcxMjMyMzEyMzEyMzExMg==
      base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(secretKey);
   }
   
   @Test
   public void encode64SecretKeyTest() {
      System.out.println(base64EncodedSecretKey);
      assertThat(secretKey, is(new String(Decoders.BASE64.decode(base64EncodedSecretKey))));
   }

   @Test
   public void generateAccessTokenTest(){
      Map<String, Object> claims = new HashMap<>();
      claims.put("memberId", 1);
      claims.put("roles", List.of("USER"));

      String subject = "test access token";
      Calendar calendar = Calendar.getInstance();
      calendar.add(Calendar.MINUTE, 10);
      Date expiration = calendar.getTime();

      String accessToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);

      System.out.println(accessToken);

      assertThat(accessToken, notNullValue());
   }
}

위와 같이 테스트 코드를 작성할 수 있다.

테스트를 실행전 secretKey값을 넣어놓고 인코딩한 값을
base64EncodedSecretKey 객체에 넣어두었다.

첫번쨰 테스트 encode64SecretKeyTest(); 메서드는
현재 인코딩된값을 다시 디코딩해 secretKey값과 비교해
같으면 테스트를 통과하는 코드이다.

두번째 테스트 generateAccessTokenTest(); 메서드는
실제로 AccessToken을 만들어보는 테스트이고
위에서 보았던 generateAccessToken();의 매개변수에
해당하는 데이터를 만들어서 메서드 실행한다음 반환값을 받으면된다.

실제 해당 반환된 토큰 accessToken를 출력해보면
eyJhbGciOiJIUzI1NiJ9.eyJyb2xlcyI6WyJVU0VSIl0sIm1lbWJlcklkIjoxLCJzdWIi OiJ0ZXN0IGFjY2VzcyB0b2tlbiIsImlhdCI6MTY2OTE5MjY0OSwiZXhwIjoxNjY5MTkzMj Q5fQ.yt_Y741NrTH5l7wsk3oYhbBqkZKgy7GNtJxsI-XuIQg

이러한 JWT 토큰이 만들어지는 것을 확인할 수 있고
위에서 공부했던 것 처럼 Header, Payload, Signature로 이루어진 것을 볼 수 있다.

해당 토큰을 JWT 사이트에서 디코딩 해보면

image

우리가 정의했던 값으로 해독이 가능한 모습을
확인해볼 수 있다.


3). RefreshToken 만들기
RefreshToken을 만드는 방법은 사실 AccessToken과
동일한 방법으로 만들면된다. 다만 차이점은 claims에 대한 내용만
빠진 상태로 만들면 된다.

아까와 동일한 클래스 JwtTokenizer에다 메서드를 추가해보자

public class JwtTokenizer {
    
   ... 생략

   public String generateRefreshToken(String subject, Date expiration, String base64EncodedSecretKey){
      Key key = getKeyFormBase64EncodedKey(base64EncodedSecretKey);

      return Jwts.builder()
              .setSubject(subject) // JWT에 대한 제목을 추가
              .setIssuedAt(Calendar.getInstance().getTime()) // JWT 발행 일자 설정
              .setExpiration(expiration) // JWT 만료일자 설정
              .signWith(key) // 서명을 위한 Key 객체를 넣어준다.
              .compact(); // JWT를 생성하고 직렬화해줌
   }
   
   ... 생략
   
}

AcessToken과 다른점은 얘기했듯이
clamis에 대한 내용만 빠진 상태로 Token을 만들어주면된다.


오늘 만난 ERROR

The specified key byte array is 248 bits which is not secure enough for any JWT HMAC-SHA algorithm.  The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HMAC-SHA algorithms MUST have a size >= 256 bits (the key size must be greater than or equal to the hash output size).  Consider using the io.jsonwebtoken.security.Keys#secretKeyFor(SignatureAlgorithm) method to create a key guaranteed to be secure enough for your preferred HMAC-SHA algorithm.  See https://tools.ietf.org/html/rfc7518#section-3.2 for more information.
io.jsonwebtoken.security.WeakKeyException: The specified key byte array is 136 bits which is not secure enough for any JWT HMAC-SHA algorithm.  The JWT JWA Specification (RFC 7518, Section 3.2) states that keys used with HMAC-SHA algorithms MUST have a size >= 256 bits (the key size must be greater than or equal to the hash output size).  Consider using the io.jsonwebtoken.security.Keys#secretKeyFor(SignatureAlgorithm) method to create a key guaranteed to be secure enough for your preferred HMAC-SHA algorithm.  See https://tools.ietf.org/html/rfc7518#section-3.2 for more information.

HMAC_SHA 알고리즘을 사용 중 발생했던 에러를 적어본다.
알람 발생 경위는 위에 에러 콘솔에서
HMAC-SHA algorithms MUST have a size >= 256 bits를 보고 알 수 있다.
SecretKey값이 256 bits가 넘지 않아서 발생한 문제로 추청되어

테스트를 진행해봤다.

 public String encodeBase64SecretKey(String secretKey){
     String encode = Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
     log.info("encodeBase64SecretKey Method 실행 { " +"secretKEY = "+ secretKey + " secretKEY.Byte = "+ secretKey.getBytes().length + " Base64 Encode = " + encode+" }");
     return encode;
 }

현재 매개변수로 String secretKey를 BASE64로 인코딩 해주는 작업이다.
secretKey = “LeeJaehyeok63763712323123123112”;
실제로 이러한 데이터를 secretKey로 입력했다.

해당 encode 객체로 아래의 메서드의 매개변수로 보내주었다.

 private Key getKeyFormBase64EncodedKey(String base64EncodedSecretKey){
     byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey); // 디코딩 진행
     Key key = (Key) Keys.hmacShaKeyFor(keyBytes); // HMAC 알고리즘을 적용한 Key 객체 생성
     return key;
 }

첫번째로 인코딩했던 문자열을 디코딩해주고
HMAC_SHA 알고리즘을 통해 Key값으로 바꾸는 작업을 했다.
이떄 여기서 에러가 발생했던 지점이었다.

.hmacShaKeyFor(); 안에서 스펙과 맞지않는 디코드값을
넣어준게 문제인 것 같다.

 private Key getKeyFormBase64EncodedKey(String base64EncodedSecretKey){
     byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey); // 디코딩 진행
        
     // ===========Test 임시 코드===========
     String test = "";
     for (byte s : keyBytes){
        test += (char)s;
     }
     System.out.println(test);
     System.out.println(keyBytes.length);
     // ==================================
     
     Key key = (Key) Keys.hmacShaKeyFor(keyBytes); // HMAC 알고리즘을 적용한 Key 객체 생성
     return key;
 }

실제로 디코딩했을때 우리가 넣어줬던

System.out.println(test);
LeeJaehyeok63763712323123123112 값이 디코딩된것이 콘솔에 출력되었고

System.out.println(keyBytes.length);
31 이라는 값이 출력되었다. 말은 즉슨 31Byte라는 것을 의미한다.

그러면 우리가 처음에 넣어줬던 secretKey는 31 Bytes였고
Bit로 환산해보면 1Byte = 8 Bit로 계산되어지니
8 * 31 = 248, 즉 248bits로 계산되어진다.

그러면 우리가 처음에 봤던 에러콘솔 첫줄에
The specified key byte array is 248 bits which is not secure enough for any JWT HMAC-SHA algorithm. 이러한 구문이 있었고, 우리가 정의한 byte가 248 bits이니까
size >= 256 bits를 넘기게 만들라는 뜻으로 해석할 수 있다.

즉, secretKey의 String Byte값을 지정했을때
32 Bytes = 256 Bits가 넘지 않아서 생기는 문제였던 것!

그래서 secretKey값을 LeeJaehyeok637637123231231231123로
뒤에 숫자하나를 추가했더니 정상적으로 작동하는 모습을 볼 수 있었다.



이렇게 오늘은 JWT와 인증과 관련된 기초지식을
먼저 배웠고, 내일부터는 Spring Security와 같이 사용해볼 것 같다.

사실 오늘은 토큰을 생성하는 법만 배웠고 더 나아간 학습을 진행하지 않았기 때문에 클라이엔트에 어떻게 보내줄지
혹시 요청을 받을때 JWT를 해독해 검증하는 과정을 어떻게할지
아직 머리속에서 마땅한 그림은 떠오르지 않는 상태이다.

모쪼록 내일까지 JWT 인증과 관련된 부분을 잘 마무리 해보자
오늘 공부는 여기서 끝 !!


오늘의 커피량: ☕️ ☕️
오늘의 점심: 삽겹살, 라면, 밥