http://changpd.blogspot.com/2021/04/jwt-1headerpayload.html
에이어서 두번째 signature 파싱처리
signature부분은 토큰의 변조 방지를 담당한다.
토큰내용이야 base64로 인코딩되어있어 얼마든지 디코딩이 가능하나
변조는 불가능해야 한다.
jwt파싱 (step1-1, SignatureAlgorithm)
첫번째 파싱한 header를 다시 JwsHeader로 타입캐스팅한다.
그리고 header에 저장된 alg값을 읽어온다.
이값이 없으면 더이상 진행이 되지 않는다.
// =============== Signature =================
if (base64UrlEncodedDigest != null) { //it is signed - validate the signature
JwsHeader jwsHeader = (JwsHeader) header;
SignatureAlgorithm algorithm = null;
if (header != null) {
String alg = jwsHeader.getAlgorithm();
if (Strings.hasText(alg)) {
algorithm = SignatureAlgorithm.forName(alg);
}
}
if (algorithm == null || algorithm == SignatureAlgorithm.NONE) {
//it is plaintext, but it has a signature. This is invalid:
String msg = "JWT string has a digest/signature, but the header does not reference a valid signature " +
"algorithm.";
throw new MalformedJwtException(msg);
}
jwt파싱(step1-2, 키설정)
대략적인 알고리즘까진 정해졌다.
담에 하는짓은 키값을 저장한다.
키값이 설정이 안되어있으면, claims, playload에서 가져온다.
//digitally signed, let's assert the signature:
Key key = this.key;
if (key == null) { //fall back to keyBytes
byte[] keyBytes = this.keyBytes;
if (Objects.isEmpty(keyBytes) && signingKeyResolver != null) { //use the signingKeyResolver
if (claims != null) {
key = signingKeyResolver.resolveSigningKey(jwsHeader, claims);
} else {
key = signingKeyResolver.resolveSigningKey(jwsHeader, payload);
}
}
if (!Objects.isEmpty(keyBytes)) {
Assert.isTrue(algorithm.isHmac(),
"Key bytes can only be specified for HMAC signatures. Please specify a PublicKey or PrivateKey instance.");
key = new SecretKeySpec(keyBytes, algorithm.getJcaName());
}
}
jwt파싱 (Step1-3, 재검증)
주석의 내용을 보면 signature 영역을 제외한 부분을 재검증을 위해 다시 생성한다.
여기서 체크하는 부분은 크게 2가지다.
첫번째는 alg,key의 유효성검사를 진행하고
//re-create the jwt part without the signature. This is what needs to be signed for verification:
String jwtWithoutSignature = base64UrlEncodedHeader + SEPARATOR_CHAR + base64UrlEncodedPayload;
JwtSignatureValidator validator;
try {
validator = createSignatureValidator(algorithm, key);
} catch (IllegalArgumentException e) {
String algName = algorithm.getValue();
String msg = "The parsed JWT indicates it was signed with the " + algName + " signature " +
"algorithm, but the specified signing key of type " + key.getClass().getName() +
" may not be used to validate " + algName + " signatures. Because the specified " +
"signing key reflects a specific and expected algorithm, and the JWT does not reflect " +
"this algorithm, it is likely that the JWT was not expected and therefore should not be " +
"trusted. Another possibility is that the parser was configured with the incorrect " +
"signing key, but this cannot be assumed for security reasons.";
throw new UnsupportedJwtException(msg, e);
}
if (!validator.isValid(jwtWithoutSignature, base64UrlEncodedDigest)) {
String msg = "JWT signature does not match locally computed signature. JWT validity cannot be " +
"asserted and should not be trusted.";
throw new SignatureException(msg);
}
두번째는 디코딩한결과의 header+payload와 Digest 영역을 재검증한다.
소스를
createSignatureValidator 함수를 쭈욱 따라가보자
signature
jwt파싱2 (만료시간, NotBeFore)
대충 여기까지 통과를 했으면, claims에 담긴 만료시간이 유효한지 체크한다.
그리고 notBefore (이시간이전에는 토큰처리안함, 엠바고 같은)
이쪽은 소스코드까지 안봐도 될듯하여 생략
jwt파싱3 (claims 유효성검사)
claims에 저장된값들의 유효성을 마지막으로 체크한다.
그냥 소스만 한번 쓰윽 보면 좋을듯
private void validateExpectedClaims(Header header, Claims claims) {
for (String expectedClaimName : expectedClaims.keySet()) {
Object expectedClaimValue = expectedClaims.get(expectedClaimName);
Object actualClaimValue = claims.get(expectedClaimName);
if (
Claims.ISSUED_AT.equals(expectedClaimName) ||
Claims.EXPIRATION.equals(expectedClaimName) ||
Claims.NOT_BEFORE.equals(expectedClaimName)
) {
expectedClaimValue = expectedClaims.get(expectedClaimName, Date.class);
actualClaimValue = claims.get(expectedClaimName, Date.class);
} else if (
expectedClaimValue instanceof Date &&
actualClaimValue != null &&
actualClaimValue instanceof Long
) {
actualClaimValue = new Date((Long)actualClaimValue);
}
InvalidClaimException invalidClaimException = null;
if (actualClaimValue == null) {
String msg = String.format(
ClaimJwtException.MISSING_EXPECTED_CLAIM_MESSAGE_TEMPLATE,
expectedClaimName, expectedClaimValue
);
invalidClaimException = new MissingClaimException(header, claims, msg);
} else if (!expectedClaimValue.equals(actualClaimValue)) {
String msg = String.format(
ClaimJwtException.INCORRECT_EXPECTED_CLAIM_MESSAGE_TEMPLATE,
expectedClaimName, expectedClaimValue, actualClaimValue
);
invalidClaimException = new IncorrectClaimException(header, claims, msg);
}
if (invalidClaimException != null) {
invalidClaimException.setClaimName(expectedClaimName);
invalidClaimException.setClaimValue(expectedClaimValue);
throw invalidClaimException;
}
}
}
여기까지 무사통과되면 return 값은 다음과 같다.
if (base64UrlEncodedDigest != null) {
return new DefaultJws<Object>((JwsHeader) header, body, base64UrlEncodedDigest);
} else {
return new DefaultJwt<Object>(header, body);
}
여기서 body는 claims 혹은 payload이다.
정리
- 파싱에서 에러가 발생하는 경우가 다양하다. 케이스에 대해 어느정도 숙지 해두는게 좋을듯
- 로그인 이후 일부 고객 정보는 payload영역에 저장할 경우 DB연결횟수를 줄일수 있다.
- 다만 header,payload는 base64로 디코딩하기 때문에 민감 정보 노출에 주의할 필요가 있다.
- signature영역이 변경될 경우 기존에 발급한 토큰은 모두 유효하지 않게 된다.
댓글
댓글 쓰기