JWT Token 인증

강서진·2024년 2월 14일

JWT (JSON Web Token)

  • 웹 표준. JSON 객체를 사용하여 가볍고 자가수용적인 방식으로 정보를 안전하게 전달할 수 있도록 설계된 토큰 기반의 인증 방식.
  • URL, HTTP Header, HTML Form 등 다양한 방식으로 전달할 수 있으며, 서버와 클라이언트 간의 인증 정보를 포함한다.

Header, Payload, Signature 세 부분으로 구성

  • Header: JWT의 타입과 암호화 알고리즘을 포함. JSON 형식으로 인코딩.
  • Payload: 클레임 정보 포함(사용자 ID, 권한 등). JSON 형식으로 인코딩.
  • Signature: Header와 Payload 조합 후 비밀키를 사용해 생성된 서명값. 토큰의 무결성을 보장 + JWT 조작하지 않았음을 검증.

JWT 구조


위 사진은 공식 사이트에 나와있는 예시로, 오른쪽 내용이 인코딩되어 왼쪽이 된다는 것을 나타내고 있다.

헤더부분은 토큰 타입과 사용된 알고리즘이 담겨있으며, Base64 URL 인코딩이 되어있다.

페이로드에는 퍼블릭값이 있고 프라이빗값이 있는데, 기본값 외에도 JSON 양식을 지킨다면 임의로 값을 추가할 수 있다. 페이로드도 마찬가지로 Base64 URL 인코딩된 값이다. 누구라도 JWT에서 이 값을 Base64로 디코딩하면 읽을 수 있기 때문에 중요한 정보보다는 참고할 수 있는 정보를 담아두는 것이 좋다.

마지막 서명 부분이 가장 중요하다. JWT 토큰은 해시 알고리즘을 통해 서명하는데, 먼저 Base 64 URL 인코딩된 헤더와 페이로드 사이에 점을 하나 추가하여 붙인다. 가지고 있는 시크릿키를 사용해 이 문자열을 해시 알고리즘으로 암호화하고, 그 값을 Base64 인코딩한 것이 서명이 된다.

어떠한 값을 어떤 키를 사용해 암호화할 경우 항상 일정한 값이 나온다. 항상 같은 키를 사용하면 항상 같은 결과값을 얻게 된다. 즉, 외부의 조작이 없다면 토큰의 서명은 항상 일정하다.

따라서 JWT는 헤더와 페이로드의 값을 가지고 해시 알고리즘을 돌려 서명이 동일한지를 인증할 수 있다. 서명이 동일하면 토큰은 유효하다. 만약 서명에 변경이 생기면 누군가가 토큰을 건드렸다는 뜻이고, 이 경우에는 토큰은 유효하지 않게 된다.

JWT의 장점은 토큰 기반의 인증 방식이기 때문에 서버 측에서 별도로 세션 저장소를 유지할 필요가 없어 유지보수가 편리해진다. 또, JSON 구조로 되어 있어 다양한 플랫폼 간에 쉽게 전송하고 구현할 수 있으며, 서명 키가 유출되지 않는 한 항상 발행을 증명할 수 있기 때문에 토큰 변조 여부를 쉽게 검증할 수 있다.

반대로 JWT의 크기가 커지면 네트워크 대역폭이 증가하게 된다. 한번 발급된 후에는 내부 정보를 수정할 수 없으므로 만료 시간이 짧으며, 탈취당하면 해당 토큰을 사용한 모든 요청이 인증되므로 보안에 위협이 될 수 있다. 따라서 JWT 토큰을 전송할 때는 HTTPS와 같은 보안 프로토콜을 사용해야 한다.

JWT을 이용한 인증 방식

  1. 클라이언트가 서버에 로그인 요청
  2. 서버는 로그인 요청을 검증하고, 유효한 사용자라면 JWT를 생성해 클라이언트에게 반환(쿠키, 리스폰스 바디, 등등)
  3. 클라이언트는 이후 요청에 JWT를 포함해 전송
  4. 서버는 JWT를 검증하고 클라이언트의 인증 여부를 판단

클라이언트가 서버가 내가 발행한 토큰을 그대로 가져왔는지를 검증한다.


실습

JWT 토큰을 사용하려면 몇가지 의존성을 build.gradledp 추가해주어야 한다. 차례대로 jjwt-api, jjwt-impl, jjwt-jackson인데, 버전은 0.11.5 버전을 사용했다.

implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'

라이브러리를 불러오고, JwtService를 만들고 안에 JWT 토큰을 생성하는 메서드와 이 토큰을 검증하는 메서드 하나를 만들어준다.

JWT를 만들려면 키를 생성해야 하는데, 키는 3종류가 있다.

  • 공개키와 비공개키를 만드는 페어 방식(keyPairFor)
  • 알고리즘을 통해 자동으로 생성된 키를 사용하는 방식(sercretKeyFor)
  • 원하는 키를 직접 커스텀해 사용하는 방식 (hmacShaKeyFor)
@Service
public class JwtService {

  private static String secretKey = "";

  public String create(
          Map<String, Object> claims,
          LocalDateTime expireAt
  ){
    var key = Keys.hmacShaKeyFor(secretKey.getBytes());
    var _expireAt = Date.from(expireAt.atZone(ZoneId.systemDefault()).toInstant());

    return Jwts.builder()
            .signWith(key, SignatureAlgorithm.HS256)
            .setClaims(claims)
            .setExpiration(_expireAt)
            .compact();
  }
  • 커스텀 방식으로 키를 생성해본다. 키를 저장하기 위해 Service 안에 static으로 secretKey 문자열을 생성하고, key에 해당 문자열의 byte를 넘겨준다.
  • 빌더를 사용하여 JWT를 만드는데, 서명 알고리즘은 공식 사이트에서 봤던 HS256을 사용한다.
  • Map으로 전달받은 claim을 그대로 넘겨준다.
  • 만료기간을 Date 형식으로 전달한다. LocalDateTime을 Date 형식으로 바꾸어 넘겨준다.
  • compact()로 문자열을 반환하게 만든다.

JWT를 직접 만들려고 하면 상당히 복잡하지만, 이렇게 이미 만들어진 라이브러리를 사용해 쉽게 토큰을 생성할 수 있다.

다음으로 검증하는 메서드를 만든다.

  public void validation(String token){
    var key = Keys.hmacShaKeyFor(secretKey.getBytes());
    var parser = Jwts.parserBuilder()
            .setSigningKey(key)
            .build();

    var result = parser.parseClaimsJws(token);
    result.getBody().entrySet().forEach(value -> {
      log.info("key: {}, value: {}", value.getKey(), value.getValue());
    });
  }
  • 동일하게 key를 생성하고, 이번에는 parserBuilder를 사용해 parser를 만든다.
  • parser는 parseClaimsJws라는 메서드를 가지고 있다. 문자열로 된 토큰을 넘겨주면 파서가 토큰을 파싱해준다.
  • 만약 파싱에 실패하면 예외가 발생한다. (-> try-catch로 예외처리를 해주어야 한다.)
  • 정상적으로 파싱되면 body에서 값을 읽어올 수 있다.

따로 Controller를 작성하지 않고 테스트 코드를 작성하여 토큰이 제대로 생성되는지 보고 검증해보려 한다.
먼저 토큰이 생성되는지 확인해본다. user_id를 923로 주고, 지금으로부터 10분 후에는 만료되도록 설정한 후 생성 메서드를 호출한다.

	@Test
	void tokenCreate(){
		var claims = new HashMap<String, Object>();
		claims.put("user_id", 923);

		var expiredAt = LocalDateTime.now().plusMinutes(10);
		var jwtToken = service.create(claims, expiredAt);
		System.out.println(jwtToken);
	}
  • 만약 sercretKey에 아무것도 작성하지 않았다면 오류가 난다.
  • key는 256비트 이상이어야 한다.

생성 메서드가 제대로 실행되면 다음과 같이 JWT 토큰이 로그에 찍히는 것을 확인할 수 있다.
이 토큰을 다시 JWT 사이트로 돌아가 복사 붙여넣기 하면 Payload에 아까 입력했던 user_id가 923으로 나오는 것을 확인할 수 있다.

  • 시크릿키를 넣지 않으면 invalid signature라고 나온다.
  • 시크릿키를 입력해주면 signature verified라고 뜬다.

이 키는 외부에서 수정하거나 변형할 수 없기 때문에 토큰에 조금이라도 변화가 생기면 토큰이 invalid해진다.

다음으로 토큰을 검증해본다.

@Test
	void tokenValidation(){
		var token = "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo5MjMsImV4cCI6MTcwNzg4OTQ2NH0.Elr7nENJLsoezRVTkyJT6AvvS0g0tqCZRQZ5wR-uUus";
		service.validation(token);
	}

생성했던 토큰을 그대로 복사해서 붙여넣고 실행해보면 테스트가 통과한다.

그러나 만약 만료기간이 10초로 짧은 토큰을 검증한다고 하면, 10초가 지난 후에는 검증에 실패할 것이다.
10초 동안 살아있는 짧은 토큰을 새로 생성하였다.

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjo5MjMsImV4cCI6MTcwNzg4OTY1NX0.FI9VndEFM4i6Eaibkt2vbYRgTnzONcpvWc3Z3KfBKFI
  • 조금 빈둥대다가 검증 테스트를 다시 돌려보면, 검증에 실패하는 것을 볼 수 있다. ExpiredJwtException이 발생했다.
  • 이번에는 토큰 헤더 값을 임의로 변경해본다. 이 경우에는 MalformedJwtException이 발생한다.
  • 페이로드의 내용을 변경하면 SignatureException이 발생한다.

토큰이 변조되면 이렇게 검증 과정에서 예외가 발생한다.
다시 service로 가서 예외를 처리해준다.

  public void validation(String token){
    var key = Keys.hmacShaKeyFor(secretKey.getBytes());
    var parser = Jwts.parserBuilder()
            .setSigningKey(key)
            .build();

    try {
      var result = parser.parseClaimsJws(token);
      result.getBody().entrySet().forEach(value -> {
        log.info("key: {}, value: {}", value.getKey(), value.getValue());
      });
    } catch (Exception e){
      if (e instanceof SignatureException){
        throw new RuntimeException("JWT Token Not Valid Exception");
      } else if (e instanceof ExpiredJwtException){
        throw new RuntimeException("JWT Token Expired Exception");
      } else {
        throw new RuntimeException("JWT Token Validation Exception");
      }
    }
  }
  • 토큰이 유효하지 않은 예외와 토큰이 만료된 예외를 구분하여 잡는다.
  • 그 외의 파싱 예외는 발생한 것만 알린다.

다음으로 30초로 만료기간을 늘린 JWT 토큰을 생성하여 다시 테스트해본다. 30초 내에 실행한 검증은 통과하였고, 30초가 지난 후 실행해보니 제대로 TokenExpiredException을 잡는 것을 확인하였다.

0개의 댓글