JWT에서의 특수문자 허용 문제

대영·2024년 7월 2일
4

트러블 슈팅

목록 보기
2/5

🙏내용에 대한 피드백은 언제나 환영입니다!!🙏

개발 환경 : jdk17, Spring Boot, jjwt 0.12.3

🚨 트러블 내용

JWT를 사용하여 Access Token으로 권한을 인증하는 과정에서 문제가 생겼다.

PostMan을 통해 테스트를 진행할다면, 헤더에 Access Token을 넣고 인증이 필요한 Restful API에 접근하는 것이 일반적이다.

하지만, 여기서 문제가 발생했다. 토큰의 맨 끝에. 즉, '서명' 부분에 특수문자가 들어가도 정상적으로 작동하는 것이었다.

예를 들어, 원래 토큰이 aaaa.bbbb.cccc였다면, aaaa.bbbb.cccc!@이러한 특수문자가 들어가도 정상적인 토큰으로 인지하고 권한은 인증되었다.
(jwt 토큰에 사용되는 '.', '-', '_'를 제외한 다른 특수문자들에 관한 내용이다.)

API 플랫폼 PostMan에서만의 문제인지 확인하기 위해 네트워크에 입력하여 넣어보니 같은 결과가 나왔다.

간단한 동작 사진이다.
1. 정상적인 토큰에 대한 흐름

2. accessToken 뒤에 특수문자를 넣는 경우. (정상적으로 작동)

2번에 대한 accessToken의 로그

INFO 2994 --- [nio-8080-exec-5] c.p.t.j.filter.JwtAuthenticationFilter   : accessToken: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJBRE1JTiIsImNhdGVnb3J5IjoiYWNjZXNzIiwidXNlcklkIjoxLCJpYXQiOjE3MjU3OTAzNTUsImV4cCI6MTcyNTc5MjE1NX0._-Nv8LjYfhSopWrwfHSTZW1xFknPBdVNe-29pzU85O8@@

❓ 왜 그런 것일까?

구글링을 통해 다양한 글을 찾아보았다. 나의 검색력이 부족한 것인지, 이것에 대한 문제를 다루는 내용을 찾지 못하였다. 또한, jwt.io사이트에 들어가도 찾을 수 없었고, jwt.io에서 제공하는 인코딩, 디코딩 변환 결과에서는 특수문자를 넣게되면 오류가 발생했다.

프레임워크마다 인지하는 것이 다른 것인지(상단에 개발 환경에 대해 간략히 적은 이유이다.), 내가 jwt에 대해서 잘못 다루는 것인지 정확한 이유를 찾는 것은 힘들었다. 그래서, 내가 고민해보고 생각한 것은 아래와 같다.


1. 토큰 디코딩 관대함.

JWT은 보통 영문자, 숫자, '_', '-', '.' 등의 내용으로 이루어진다. 그래서, 디코딩 하는 과정에서 맨 뒤에 붙은 !@#이러한 특수문자는 제외시키는 일이 일어난다는 생각을 하였다. (물론, 맨 뒤를 제외하고 사이사이에 특수문자가 들어가면 잘못된 토큰임을 인지한다.)

2. 서명 검증에서의 통과.

JWT는 헤더, 페이로드, 서명으로 이루어 지고, 서명은 헤더와 페이로드 + secretKey를 통해서 만들어진다.
그래서, 서버는 수신된 헤더와 페이로드, secretKey를 사용하여 서명을 조합해 서명이 올바른지 확인하며 '무결성'을 확인한다.
이러한 과정에서 서명이 올바르다면 뒤에 특수문자와 상관없이 그대로 인증을 통과시킨다고 생각을 하였다. (1번과 이유가 비슷한가..?)


어디까지나 나의 생각이고, 이것에 대해 그냥 재밌게 읽어줬으면 좋겠다.


🔥 추가 내용 (특수문자 허용 이유)

추가적으로 정보를 찾고, Stackoverflow 커뮤니티에 물어본 결과 아래와 같은 깃허브 레포지토리의 README.md 와 이슈를 발견하게 되었다.

jjwt GitHub 중 Adding Invalid Characters 부분

특수문자 허용에 대한 의문점 제기 이슈

간단히 요약하자면, 아래와 같다.
<< jjwt GitHub 중 Adding Invalid Characters의 내용 >>

  1. Base64 또는 Base64URL 디코더가 기본적으로 잘못된 Base64 문자를 무시하는 이유를 설명이다.
    why? 실제 서명 자체를 변경하지 않기 때문.
  2. 서명은 항상 바이트 배열로 처리되며, 텍스트로 인코딩된 바이트 배열을 변경해도 실제 서명 데이터는 변경되지 않는다.
    => JJWT는 암호화 작업에서 바이트 배열이 중요하다고 보고, 텍스트 인코딩은 덜 중요하게 취급. 하지만, 실제 바이트 배열이 변경되면 JJWT 암호검사는 실패.
  3. 서명의 존재 이유는 두 가지를 보장하기 위해서이다.
    3.1 서명이 우리가 아는 누군가에 의해 생성되었는지(진위 확인).
    3.2 생성된 이후에 아무도 서명을 변경하거나 조작하지 않았는지(무결성 유지).
    => 단순히 잘못된 문자를 추가한다고 해서 알고리즘을 속이는 것이 아니며, 바이트 배열의 무결성이나 진위를 변경하지 않기 때문에 문제가 되지 않는다는 뜻.

<< 특수문자 허용에 대한 의문점 제기 이슈 내용 >>

  1. 초기 설계에서 이러한 동작은 "강건성 원칙"에 따라 의도적으로 결정
  2. 서명을 검증할 때 잘못된 문자가 있어도 서명의 무결성에 영향을 주지 않기 때문에 실패할 필요가 없다고 설명.
  3. JJWT의 서명 검증은 기본적으로 암호화된 서명을 기준으로 하며, 단순히 텍스트 레벨에서 발생하는 문제는 보안에 실질적인 영향을 미치지 않는다고 생각.
  4. 하지만 이 문제는 추후 JJWT의 주요 업데이트에서 수정할 가능성이 있으며, 현재로서는 커스텀 Base64 디코더를 사용하는 것이 가장 쉬운 해결책이라고 제안. (오래전부터 사용해왔고, 기존 동작 방식에 의존하는 서비스가 많을 것이기에 업데이트하기에는 중요한 논점이다.)

    강건성의 원칙이란, "너그러이 수용하고, 엄격하게 보낸다."는 뜻.
    이 원칙의 목표는 시스템의 견고함을 높이고, 다양한 입력을 잘 처리하면서도 정확하고 신뢰성 있는 출력을 유지하는 것이다.


내가 생각했던 이유가 정확하진 않지만, 너그럽게 수용한다는 점에서 같은 의미인 것 같다. 나는, 사용자가 로그아웃을 한다면, 그 사용자의 AccessToken으로의 접근은 허용하지 않도록 BlackList 메모리에 담아 놓을 것이기 때문에, 아래와 같은 해결방법을 적용해보았다.

👉 해결방법

  1. 간단한 해결 방법이다. 그냥 검증을 해주는 과정이다. (나의 방법)
    JWT를 통하여 인증을 처리하는 필터에서 특수문자를 처리해주는 코드를 넣었다.
    간단히 보자면 아래와 같다. 시작과 끝 사이에 영문자, 하이픈(-), 언더바(_), 점(.)으로 이루어져야 한다.
	public boolean isBase64URL(String token) {
        return token.matches("^[0-9A-Za-z-_.]+$");
    }

	String accessToken = accessTokenGetHeader.substring(TOKEN_PREFIX.length()).trim();

	if (!isBase64URL(accessToken)) {
    	// 예외 처리.
    }
  1. 위에서 개발자가 한 답변인 Base64를 커스텀하는 것이다.
  • base64url 인코더 구현
public class CustomBase64UrlEncoder implements Encoder<OutputStream, OutputStream> {

	@Override
    public OutputStream encode(OutputStream outputStream) {
        // OutputStream을 Base64 URL-safe로 인코딩
        Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding();
        return encoder.wrap(outputStream);  // Base64로 인코딩한 결과를 OutputStream으로 반환
    }
}
  • base64url 디코더 구현

public class CustomBase64UrlDecoder implements Decoder<InputStream, InputStream> {

	@Override
    public InputStream decode(InputStream inputStream) {
        try {
            byte[] bytes = inputStream.readAllBytes(); // InputStream을 바이트 배열로 변환
            byte[] decodedBytes = Base64.getUrlDecoder().decode(bytes); // Base64 URL-safe 디코딩
            return new ByteArrayInputStream(decodedBytes); // 디코딩된 결과를 새로운 InputStream으로 반환
        } catch (Exception e) {
            throw new RuntimeException("Decoding failed", e);
        }
    }
}
  • 커스텀 인코더 설정
	private final Encoder<OutputStream, OutputStream> base64UrlEncoder = new CustomBase64UrlEncoder();

	/* 토큰 생성 */
    private String createJwt(Map<String, Object> claims, String subject, Long expirationTime) {

        return Jwts.builder()
                .subject(subject)
                .claims(claims)
                .b64Url(base64UrlEncoder) // 커스텀 인코더 설정
                .issuer(issuer)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expirationTime))
                .signWith(secretKey)
                .compact();
    }
  • 커스텀 디코더 설정
	private final Decoder<InputStream, InputStream> base64UrlDecoder = new CustomBase64UrlDecoder();
    
    /* 토큰 정보 불러오기 */
    private Claims parseClaims(String token){
        return Jwts.parser()
                .verifyWith(secretKey)
                .b64Url(base64UrlDecoder)	// 커스텀 디코더 설정
                .build()
                .parseSignedClaims(token)
                .getPayload();
    }

💡느낀점

내가 잘못된 코드를 짠 것인지 알 수 없었다. 하지만, 깃허브를 통해 다양한 사람들이 짠 코드를 보았고, 그 코드들을 적용시켜도 똑같은 결과가 나와 특수문자 처리에 대한 의심을 해보게 된 것이다.

또한, 특수문자 처리가 중요한가? 라는 생각을 가질 수 있다.
나는 개인의 정보가 사용되는 로그인 부분은 보안이 아주 중요하다고 생각한다. 그래서, 로그아웃을 하면 Access Token을 만료기간 까지 BlackList에 담아 사용하지 못하게 만드는 과정을 만들었다. 이러한 기능을 넣으면, 위처럼 특수문자를 사용할 시 그대로 접근이 되는 문제가 발생한다.

이와 같이, 보안은 중요하다고 생각하고, 좀 더 알아볼 것이며, 이러한 문제에 대한 정확한 이유를 파악하고 싶다. (위에 이유에 내용을 추가함.)

profile
Better than yesterday.

0개의 댓글