🙏내용에 대한 피드백은 언제나 환영입니다!!🙏
개발 환경 : 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은 보통 영문자, 숫자, '_', '-', '.' 등의 내용으로 이루어진다. 그래서, 디코딩 하는 과정에서 맨 뒤에 붙은 !@#이러한 특수문자는 제외시키는 일이 일어난다는 생각을 하였다. (물론, 맨 뒤를 제외하고 사이사이에 특수문자가 들어가면 잘못된 토큰임을 인지한다.)
JWT는 헤더, 페이로드, 서명으로 이루어 지고, 서명은 헤더와 페이로드 + secretKey를 통해서 만들어진다.
그래서, 서버는 수신된 헤더와 페이로드, secretKey를 사용하여 서명을 조합해 서명이 올바른지 확인하며 '무결성'을 확인한다.
이러한 과정에서 서명이 올바르다면 뒤에 특수문자와 상관없이 그대로 인증을 통과시킨다고 생각을 하였다. (1번과 이유가 비슷한가..?)
이후에 행한 방법은 2가지였다.
디버깅을 통해서 코드를 타고 들어가면 Base64클래스를 볼 수 있다.
디코딩 과정에서 필요한 내용을 보자면 아래와 같다.
final class Base64 {
private static final char[] BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();
private static final char[] BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".toCharArray();
private static final int[] BASE64_IALPHABET = new int[256];
private static final int[] BASE64URL_IALPHABET = new int[256];
.
.
.
private final char[] ALPHABET;
private final int[] IALPHABET;
private Base64(boolean urlsafe) {
this.urlsafe = urlsafe;
this.ALPHABET = urlsafe ? BASE64URL_ALPHABET : BASE64_ALPHABET;
this.IALPHABET = urlsafe ? BASE64URL_IALPHABET : BASE64_IALPHABET;
}
byte[] decodeFast(CharSequence seq) throws DecodingException {
.
.
.
} else {
int sIx = 0;
int eIx;
for(eIx = sLen - 1; sIx < eIx && this.IALPHABET[seq.charAt(sIx)] < 0; ++sIx) {
}
while(eIx > 0 && this.IALPHABET[seq.charAt(eIx)] < 0) {
--eIx;
}
int pad = seq.charAt(eIx) == '=' ? (seq.charAt(eIx - 1) == '=' ? 2 : 1) : 0;
int cCnt = eIx - sIx + 1;
int sepCnt = sLen > 76 ? (seq.charAt(76) == '\r' ? cCnt / 78 : 0) << 1 : 0;
int len = ((cCnt - sepCnt) * 6 >> 3) - pad;
byte[] dArr = new byte[len];
int d = 0;
int i = 0;
int r = len / 3 * 3;
.
.
.
}
}
static {
IALPHABET_MAX_INDEX = BASE64_IALPHABET.length - 1;
Arrays.fill(BASE64_IALPHABET, -1);
System.arraycopy(BASE64_IALPHABET, 0, BASE64URL_IALPHABET, 0, BASE64_IALPHABET.length);
int i = 0;
for(int iS = BASE64_ALPHABET.length; i < iS; BASE64URL_IALPHABET[BASE64URL_ALPHABET[i]] = i++) {
BASE64_IALPHABET[BASE64_ALPHABET[i]] = i;
}
.
.
.
}
}
주석에 적으면 불편하니 위의 코드 중에서도 의미하는 것을 하나씩 보겠다.
private static final char[] BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();
private static final char[] BASE64URL_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".toCharArray();
private static final int[] BASE64_IALPHABET = new int[256];
private static final int[] BASE64URL_IALPHABET = new int[256];
위에서 BASE64_ALPHABET과 BASE64URL_ALPHABET는 각각 Base64, Base64URL에 사용되는 내용들이다.
그리고, BASE64_IALPHABET과 BASE64URL_IALPHABET는 아스키 코드 0 ~ 255를 표현할 공간이라고 보면 된다.
이것과 함께 볼 내용은 static 초기화 블록이다.
static {
IALPHABET_MAX_INDEX = BASE64_IALPHABET.length - 1;
Arrays.fill(BASE64_IALPHABET, -1);
System.arraycopy(BASE64_IALPHABET, 0, BASE64URL_IALPHABET, 0, BASE64_IALPHABET.length);
int i = 0;
for(int iS = BASE64_ALPHABET.length; i < iS; BASE64URL_IALPHABET[BASE64URL_ALPHABET[i]] = i++) {
BASE64_IALPHABET[BASE64_ALPHABET[i]] = i;
}
.
.
.
}
우선 Arrays.fill()과 System.arraycopy()를 통해서 255크기의 배열인 BASE64_IALPHABET과 BASE64URL_IALPHABET을 -1로 초기화 한다.
그 이후에, for문을 통해서 클래스 변수에서 정의해둔 BASE64_ALPHABET의 크기에 맞게 Base64, Base64URL에 실제로 사용되는 64개의 값들만 초기화를 진행한다. 즉, 해당 값들은 -1이 아닌 그에 맞는 값으로 초기화 되는 것이다.
이제 볼 것은 decodeFast 메서드 이다.
byte[] decodeFast(CharSequence seq) throws DecodingException {
int sLen = seq != null ? seq.length() : 0;
.
.
.
} else {
int sIx = 0;
int eIx;
for(eIx = sLen - 1; sIx < eIx && this.IALPHABET[seq.charAt(sIx)] < 0; ++sIx) {
}
while(eIx > 0 && this.IALPHABET[seq.charAt(eIx)] < 0) {
--eIx;
}
int pad = seq.charAt(eIx) == '=' ? (seq.charAt(eIx - 1) == '=' ? 2 : 1) : 0;
int cCnt = eIx - sIx + 1;
int sepCnt = sLen > 76 ? (seq.charAt(76) == '\r' ? cCnt / 78 : 0) << 1 : 0;
int len = ((cCnt - sepCnt) * 6 >> 3) - pad;
byte[] dArr = new byte[len];
int d = 0;
int i = 0;
int r = len / 3 * 3;
.
.
.
}
}
위에서도 이 글에서 가장 중요한 부분은 아래의 부분이다
for (eIx = sLen - 1; sIx < eIx && this.IALPHABET[seq.charAt(sIx)] < 0; ++sIx) {}
while (eIx > 0 && this.IALPHABET[seq.charAt(eIx)] < 0) {
--eIx;
}
위 코드는 입력 문자열에서 디코딩 대상이 아닌 문자들, 즉 Base64에 해당하지 않는 문자(예: 공백, 개행, @, #, ! 같은 특수문자 등)를 앞뒤에서 제거하는 역할을 한다.
이때 사용되는 IALPHABET 배열은 아스키 코드값을 인덱스로 하여 해당 문자가 Base64 알파벳인지 여부를 -1 또는 0~63으로 나타낸다. 따라서 IALPHABET < 0인 경우는 Base64에서 유효하지 않은 문자로 간주되어 디코딩 대상에서 자동으로 무시된다.
이 부분에서 확인할 수 있는건, 디코딩 과정에서 양 끝의 특수문자를 지우고 byte배열로 바꾸기 때문에 정상적으로 디코딩이 된다.
이렇게 되면, 헤더, 페이로드에서도 특수문자가 허용되는 것이 아닌가?
그것을 보기 위해서는DefaultJwtParser 클래스에서의 verifySignature 메서드를 봐야한다.
우선, 서명의 경우 인코딩된 헤더 + 인코딩된 페이로드 + 시크릿키의 조합으로 만들어진다. 그러면, 서명을 검증하기 위해서는 똑같이 인코딩된 헤더 + 인코딩된 페이로드가 필요하다.
과정의 코드를 보자면,
private void verifySignature(TokenizedJwt tokenized, JwsHeader jwsHeader, String alg, SigningKeyResolver resolver, Claims claims, Payload payload) {
.
.
.
} else {
byte[] signature = this.decode(tokenized.getDigest(), "JWS signature");
InputStream payloadStream = null;
Object verificationInput;
if (jwsHeader.isPayloadEncoded()) {
int len = tokenized.getProtected().length() + 1 + tokenized.getPayload().length();
CharBuffer cb = CharBuffer.allocate(len);
cb.put(Strings.wrap(tokenized.getProtected()));
cb.put('.');
cb.put(Strings.wrap(tokenized.getPayload()));
cb.rewind();
ByteBuffer bb = StandardCharsets.US_ASCII.encode(cb);
bb.rewind();
byte[] data = new byte[bb.remaining()];
bb.get(data);
verificationInput = Streams.of(data);
}
.
.
.
try {
String msg;
try {
VerifySecureDigestRequest<Key> request = new DefaultVerifySecureDigestRequest((InputStream)verificationInput, provider, (SecureRandom)null, key, signature);
if (!algorithm.verify(request)) {
msg = "JWT signature does not match locally computed signature. JWT validity cannot be asserted and should not be trusted.";
throw new SignatureException(msg);
}
}
.
.
.
이 곳에서 cb.put(Strings.wrap(tokenized.getProtected()));와 cb.put(Strings.wrap(tokenized.getPayload())); 부분이 바로 인코딩된 헤더, 페이로드이다.
이것은, 위 a, b, c에서 보았던 디코딩 과정과는 상관이 없고, 인코딩된 헤더와 페이로드를 사용하기 때문에 특수문자가 있으면 문제가 발생한다.
그리고, 그 아래에
VerifySecureDigestRequest<Key> request = new DefaultVerifySecureDigestRequest((InputStream)verificationInput, provider, (SecureRandom)null, key, signature);
if (!algorithm.verify(request)) {
코드에서 만들어진 verificationInput을 사용하고, if (!algorithm.verify(request))을 통해서 서명 확인 과정 중, 잘못된 값이 발생하면 SignatureException가 발생하여 잘못된 서명 오류가 발생하는 것이다.
강건성의 원칙이다.)결국에는
1. byte배열로 디코딩 중, 특수문자가 양 끝에 있더라도 에러가 발생하지 않는다.
2. 헤더와 페이로드 또한, 디코딩 과정에서는 양 끝에 특수문자가 있어도 에러가 발생하지 않는다. 하지만, 서명 검증 과정에서 디코딩된 헤더, 페이로드가 아닌 인코딩된 헤더, 페이로드를 사용하기 때문에 특수문자로 인해 에러가 발생한다.
아래와 같은 GitHub 내용이 있다.
jjwt GitHub 중 Adding Invalid Characters 부분
간단히 요약하자면, 아래와 같다.
<< jjwt GitHub 중 Adding Invalid Characters의 내용 >>
<< 특수문자 허용에 대한 의문점 제기 이슈 내용 >>
강건성의 원칙이란, "너그러이 수용하고, 엄격하게 보낸다."는 뜻.
이 원칙의 목표는 시스템의 견고함을 높이고, 다양한 입력을 잘 처리하면서도 정확하고 신뢰성 있는 출력을 유지하는 것이다.
내가 생각했던 이유가 정확하진 않지만, 너그럽게 수용한다는 점에서 같은 의미인 것 같다. 나는, 사용자가 로그아웃을 한다면, 그 사용자의 AccessToken으로의 접근은 허용하지 않도록 BlackList 메모리에 담아 놓을 것이기 때문에, 아래와 같은 해결방법을 적용해보았다.
public boolean isBase64URL(String token) {
return token.matches("^[0-9A-Za-z-_.]+$");
}
String accessToken = accessTokenGetHeader.substring(TOKEN_PREFIX.length()).trim();
if (!isBase64URL(accessToken)) {
// 예외 처리.
}
하지만, 이거는 JWT 구조적으로 해결하는 방식이 아니다.
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으로 반환
}
}
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에 담아 사용하지 못하게 만드는 과정을 만들었다. 이러한 기능을 넣으면, 위처럼 특수문자를 사용할 시 그대로 접근이 되는 문제가 발생한다.
왜 이렇게 처리를 했는지, 이것을 해결하기 위해서는 어떻게 접근하여 해결해야하는지를 항상 생각을 하며 개발을 해야겠다고 다시 느꼈다.