Development note/Java

[Java] JWT(Json Web Token)을 발행, 확인하는 방법

v명월v 2022. 3. 14. 19:08

안녕하세요. 명월입니다.


이 글은 Java에서 JWT(Json Web Token)을 발행, 확인하는 방법에 대한 글입니다.


저는 지금까지 웹 환경에서 로그인, 로그아웃 기능을 만들 때 보통 세션을 이용한 방법으로 로그인, 로그아웃을 사용했습니다. 사실 최근까지도 그렇게 사용했습니다.

세션에 정보를 넣는다 해도 쿠키의 세션 ID를 탈취하면 보안에 문제가 생기는 건 마찬가지지만 제가 알기로는 그나마 가장 보편적으로 사용하는 인증 방식이지 않을까 생각합니다.


최근에 프로젝트 형식이 마이크로 서비스 아키틱처에서 모듈별로 서버를 분할하거나 대용량 트래픽에 맞추어서 웹 서버 로드 밸런싱으로 여러 서버에 트래픽 분산 형식으로 많이 작성합니다.

그럴 경우 문제가 로그인 세션을 어떻게 구성하는 것입니다. 가장 많이 사용하는 방법으로는 하나의 세션 서버를 만들어서 Redis 데이터베이스를 설치하고 각 서버에서 Redis 서버에 세션 체크를 하는 것으로 대응이 가능합니다.

그런데 이것도 만능이 아니라서 마이크로 서비스로 웹 서버를 극단적으로 분할을 하게 된다면 세션 서버의 부하가 걸리고 여러가지 이유가 있겠네요..

사실 저의 경우는 그런 상황에서 JWT를 사용한 건 아니고 프로트엔드와 백엔드의 작업을 분리하는 과정에서 보안을 생각했을 때 좀 더 효과적으로 로그인 관리 기능을 사용하는 방법이 없을까 고민하던 차에 JWT라는 것을 알게 되었습니다.

링크 - https://jwt.io/introduction


JWT의 기능을 이해하는데 저도 좀 시간이 걸렸습니다. 사실 생각해 보면 굉장히 단순한 논리였는데.. 로그인 정보를 서버에 두어야 한다는 고정관념 때문인가 이게 과연 보안이 유용할까를 계속 고민했던 것 같습니다.

사실 인증만 된다고 하면 그 정보를 굳이 서버의 세션에 둘 필요는 없었는데.. 생각해 보면 지금까지 그렇게 비효율적으로 로그인 정보를 두었을까 생각이 되네요...

출처 - https://ansibytecode.com/jwt-peek-into-the-jargon-java-web-token/

JWT는 위 이미지처럼 XXXXX.XXXXX.XXXXX의 구조로 되어있습니다.

먼저 Header는 토큰 타입이나 알고리즘 정보에 대해서 설정되어 있습니다.

그리고 Payload는 세션처럼 사용할 정보가 담겨져 있습니다. 물론 여기에는 유저 정보가 담겨져 있으면 안됩니다. 이름이나 아이디 정도는 괜찮치만 패스워드나 개인정보가 있으면 탈취당할 수 있겠네요.

마지막 Signature는 토큰 정보가 맞는 정보인지 확인하는 코드가 담겨져 있습니다.


저는 이것을 주민등록증으로 생각하고 이해했습니다. 사실 그렇게 생각하니 모든게 쉽게 이해가 되더라고요.

우리 주민등록증의 경우는 앞에 생년월일이 있고, 다음에는 구분자, 즉 남성 여성, 태어난 지역과 등록된 동사무소 번호, 등록한 순번까지의 정보가 있습니다. 그러고 가장 마지막 번호가 Signature인 셈입니다. 즉, 우리가 주민 등록 번호를 조회하지 않더라도 번호만으로 일단 맞는 번호인지 아닌지를 확인할 수 있습니다.

JWT도 Signature 정보로 맞는 토큰인지 아닌지를 확인할 수 있습니다. 즉, 임의로 변조해서는 사용할 수 없게 해 놓은 것입니다.

이런 기능을 이용하게 된다면 쿠키에 SESSION-ID를 넣어서 서버의 세션를 이용해서 로그인이 되었는지 확인 할 필요가 없습니다.


즉, 단순하게 JWT로 이 토큰은 우리가 사용하는 서비스에서 유용한 토큰인지 확인할 수 있고, 좀 더 보안을 강화하고 싶으면 발행했을 때의 Signature를 Redis의 데이터베이스에 넣어서 확인하는 것으로 정확한 로그인 여부를 확인할 수 있습니다.


JWT를 Java에서 어떻게 Token를 생성하는지 그리고 그 값이 맞는 값인지 확인하기 위한 방법에 대해 소개하겠습니다.

사실 이 JWT는 Web 환경에서 사용하는 것인데 웹 환경의 설정과 복합적으로 설명하게 되면 복잡하게 되지 단순하게 콘솔에서 발행 비교하는 것으로 설명해서 그것을 로그인과 web-filter에 대신 사용하게 되면 좋지 않을까 싶네요.


먼저 Java에서 JWT를 사용하기 위해서는 maven으로 라이브러리를 하나 연결해야 합니다.

링크 - https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt/0.9.1

<dependencies>
  <dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
  </dependency>
  <dependency>
      <groupId>javax.xml.bind</groupId>
      <artifactId>jaxb-api</artifactId>
      <version>2.1</version>
  </dependency>
</dependencies>

제가 위 소스에 javax.xml.bind도 추가했습니다만 이게 xml를 분석, 생성하는 라이브러리입니다. Web환경에서는 굳이 선언하지 않아도 web 라이브러리에 포함되어 있는 라이브러리입니다만, 콘솔에서는 없으니깐 따로 xml를 분석하는 라이브러리가 없이니 의존성으로 선언을 했습니다.

package jwtTest;

import java.util.Date;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;

// main 함수가 있는 클래스
public class Program {
  // 암호화하기 위한 키
  private static String SECRET_KEY = "secret";
  // JWT 만료 시간 1시간
  private static long tokenValidMilisecond = 1000L * 60 * 60;
  // 실행 함수
  public static void main(String[] args) {
    // Program 인스턴스 생성
    var p = new Program();
    // JWT 토큰 생성 - id는 nowonbun
    var token = p.createToken("nowonbun");
    // 콘솔 출력
    System.out.println(token);
    // JWT 토큰 복호화
    var claims = p.getClaims(token);
    // JWT 토큰 검증
    if (claims != null && p.validateToken(claims)) {
      // id를 취득한다.
      var id = p.getKey(claims);
      // Payload 값의 test 키의 값을 취득
      var test = p.getClaims(claims, "test");
      // 콘솔 출력
      System.out.println(id);
      System.out.println(test);
    } else {
      // 토큰이 정합성이 맞지 않으면
      System.out.println("error");
    }
  }
  // 토큰 생성 함수
  public String createToken(String key) {
    // Claims을 생성
    var claims = Jwts.claims().setId(key);
    // Payload 데이터 추가
    claims.put("test", "Hello world");
    // 현재 시간
    Date now = new Date();
    // JWT 토큰을 만드는데, Payload 정보와 생성시간, 만료시간, 알고리즘 종류와 암호화 키를 넣어 암호화 함.
    return Jwts.builder()
               .setClaims(claims)
               .setIssuedAt(now)
               .setExpiration(new Date(now.getTime() + tokenValidMilisecond))
               .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
               .compact();
  }
  // String으로 된 코드를 복호화한다.
  public Jws<Claims> getClaims(String jwt) {
    try {
      // 암호화 키로 복호화한다.
      // 즉 암호화 키가 다르면 에러가 발생한다.
      return Jwts.parser()
                 .setSigningKey(SECRET_KEY)
                 .parseClaimsJws(jwt);
    }catch(SignatureException e) {
      return null;
    }
  }
  // 토큰 검증 함수
  public boolean validateToken(Jws<Claims> claims) {
    // 토큰 만료 시간이 현재 시간을 지났는지 검증
    return !claims.getBody()
                  .getExpiration()
                  .before(new Date());
  }
  // 토큰을 통해 Payload의 ID를 취득
  public String getKey(Jws<Claims> claims) {
    // Id 취득
    return claims.getBody()
                 .getId();
  }
  // 토큰을 통해 Payload의 데이터를 취득
  public Object getClaims(Jws<Claims> claims, String key) {
    // 데이터 취득
    return claims.getBody()
                 .get(key);
  }
}

먼저 JWT 암호화 과정에서 SECRET_KEY라는 것이 필요합니다. 이 SECRET_KEY란 고유의 암호화 키로 SECRET_KEY가 다른 PC에서 암호화된 토큰 데이터를 가지고 있으면 검증 과정에서 에러가 발생하게 됩니다.

즉, SECRET_KEY만 탈취되지 않으면 주민등록증처럼 TOKEN 키를 사용할 수 있는 것입니다.


이 토큰을 가지고 복호화를 해보곘습니다.

링크 - https://jwt.io

jwt 사이트에서 복호화를 해보니 우리가 넣었던 데이터가 표시가 됩니다. 마지막 Signature 검증은 SECRET_KEY를 우리만 알고 있기 때문에 올바른 키인지 검사는 프로그램 상에서만 확인이 됩니다.

여기까지가 일단 JWT 토큰을 만들고 검증하는 과정입니다.


사실 이것을 그대로 사용하기에는 조금 위험성이 있습니다. 왜냐하면 Payload 값이 그대로 보이기 때문입니다.

우리가 로그인 정보를 세션에 넣을때 단순하게 id만 넣는 것이 아니고 유저 정보도 넣는 경우가 많이 있습니다. 즉, 너무 민감한 정보를 넣는 것은 위험성이 있지만 간단한 정보 정도는 넣고 싶네요.

그러나 Payload의 값은 위처럼 사이트에서 복호화가 가능하기 때문에 좀 더 암호화가 필요합니다.

package jwtTest;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.util.Base64;
import java.util.Date;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;

// User 클래스 (직렬화 인터페이스 상속)
class User implements Serializable {
  private static final long serialVersionUID = 1L;
  // 맴버 변수
  private String id;
  private String userName;
  // setter
  public void setId(String id) {
    this.id = id;
  }
  public void setUserName(String userName) {
    this.userName = userName;
  }
  // 맴버 변수 출력 함수
  public void print() {
    // 콘솔 출력
    System.out.println("id = " + this.id + " userName = " + this.userName);
  }
}

// main 함수가 있는 클래스
public class Program {
  // 암호화하기 위한 키
  private static String SECRET_KEY = "secret";
  // JWT 만료 시간 1시간
  private static long tokenValidMilisecond = 1000L * 60 * 60;

  // 실행 함수
  public static void main(String[] args) {
    // 설정한 암호화키를 Base64로 암호화한다.
    SECRET_KEY = Base64.getEncoder().encodeToString(SECRET_KEY.getBytes());
    // User 인스턴스 생성
    var user = new User();
    // Id 설정
    user.setId("nowonbun");
    // UserName 설정
    user.setUserName("hello world");
    // Program 인스턴스 생성
    var p = new Program();
    // User 인스턴스를 직렬화한다.
    var code = p.convertSerializable(user);
    // 직렬화된 코드에 1 코드를 추가한다.
    code = "1" + code;
    // JWT 토큰 생성
    var token = p.createToken(code);
    // 콘솔 출력
    System.out.println(token);
    // JWT 토큰 복호화
    var claims = p.getClaims(token);
    // JWT 토큰 검증
    if (claims != null && p.validateToken(claims)) {
      // id를 취득한다.
      var id = p.getKey(claims);
      // 콘솔 출력
      System.out.println(id);
      // 1코드를 제거
      id = id.substring(1);
      // 역직렬화
      user = p.convertData(id);
      // 콘솔 출력
      user.print();
    } else {
      // 토큰이 정합성이 맞지 않으면
      System.out.println("error");
    }
  }
  // 직렬화 함수
  public String convertSerializable(User user) {
    try (var baos = new ByteArrayOutputStream()) {
      try (var oos = new ObjectOutputStream(baos)) {
        oos.writeObject(user);
        // 직렬화 코드
        var data = baos.toByteArray();
        // 직렬화된 것은 Base64로 암호화
        return Base64.getEncoder().encodeToString(data);
      }
    } catch (Throwable e) {
      e.printStackTrace();
      return null;
    }
  }
  // 역직렬화 함수
  public User convertData(String code) {
    // Base64 복호화
    var data = Base64.getDecoder().decode(code);
    // 역직렬화
    try (var bais = new ByteArrayInputStream(data)) {
      try (var ois = new ObjectInputStream(bais)) {
        Object objectMember = ois.readObject();
        // User 인스턴스로 캐스팅
        return (User) objectMember;
      }
    } catch (Throwable e) {
      e.printStackTrace();
      return null;
    }
  }

  // 토큰 생성 함수
  public String createToken(String key) {
    // Claims을 생성
    var claims = Jwts.claims().setId(key);
    // 현재 시간
    Date now = new Date();
    // JWT 토큰을 만드는데, Payload 정보와 생성시간, 만료시간, 알고리즘 종료와 암호화 키를 넣어 암호화 함.
    return Jwts.builder()
               .setClaims(claims)
               .setIssuedAt(now)
               .setExpiration(new Date(now.getTime() + tokenValidMilisecond))
               .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
               .compact();
  }

  // String으로 된 코드를 복호화한다.
  public Jws<Claims> getClaims(String jwt) {
    try {
      // 암호화 키로 복호화한다.
      // 즉 암호화 키가 다르면 에러가 발생한다.
      return Jwts.parser()
                 .setSigningKey(SECRET_KEY)
                 .parseClaimsJws(jwt);
    } catch (SignatureException e) {
      return null;
    }
  }

  // 토큰 검증 함수
  public boolean validateToken(Jws<Claims> claims) {
    // 토큰 만료 시간이 현재 시간을 지났는지 검증
    return !claims.getBody()
                  .getExpiration()
                  .before(new Date());
  }

  // 토큰을 통해 Payload의 ID를 취득
  public String getKey(Jws<Claims> claims) {
    // Id 취득
    return claims.getBody().getId();
  }

  // 토큰을 통해 Payload의 데이터를 취득
  public Object getClaims(Jws<Claims> claims, String key) {
    // 데이터 취득
    return claims.getBody().get(key);
  }
}

User 클래스를 만들어서 직렬화하여 JWT의 id에 넣습니다. 그럼 직접적으로 정보를 해독할 수 는 없습니다.

그런데 눈썰미가 있는 사람은 뒤에 ==가 있는 것으로 이건 Base64코드라는 것을 알 수 있습니다. 즉, Base64를 복호화하면 알 수 있지 않을까하는 것입니다. 그러서 제가 임의의 데이터 1를 추가했습니다. 즉, 단순하게는 Base64 복호화가 안됩니다.

사실 저는 그냥 1를 넣었기 때문에 눈치 빠른 사람은 알 수 있겠지만.. 위 코드를 Ascii코드로 1번째부터 5번째가 반전 데이터를 넣으면 분석하기 어렵습니다. 특히 위 데이터는 Java 클래스를 직렬화로 되어 있기 때문에, 이 정도의 암호화 작업이면 사양을 알지 않는 이상 복호화가 힘들겠네요..

이렇게 한다면 로그인 정보를 세션에 넣지 않아도 여러 서버에서 SECRET_KEY와 암복호화 과정만 일치시킨다면 세션 클러스터링을 하지 않아도 정보를 공유하지 않을까 싶네요.


여기까지 Java에서 JWT(Json Web Token)을 발행, 확인하는 방법에 대한 글이었습니다.


궁금한 점이나 잘못된 점이 있으면 댓글 부탁드립니다.