feat: sign JSON Web Token with HmacSHA algorithms

This commit is contained in:
zihluwang
2025-04-02 22:40:22 +08:00
parent fdf3263373
commit ad5b5ba146
6 changed files with 636 additions and 0 deletions
@@ -0,0 +1,32 @@
/*
* Copyright (C) 2024-2025 OnixByte.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.onixbyte.jwt;
import com.fasterxml.jackson.core.JsonProcessingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
/**
*
*/
public interface TokenCreator {
String sign(TokenPayload payload);
}
@@ -0,0 +1,56 @@
/*
* Copyright (C) 2024-2025 OnixByte.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.onixbyte.jwt;
import com.onixbyte.jwt.data.RawTokenComponent;
import java.util.Map;
/**
*
*/
public interface TokenResolver {
/**
*
* @param token
*/
void verify(String token);
/**
*
* @param token
* @return
*/
Map<String, String> getHeader(String token);
/**
*
* @param payload
* @return
*/
Map<String, Object> getPayload(String payload);
/**
*
* @param token
* @return
*/
RawTokenComponent splitToken(String token);
}
@@ -0,0 +1,115 @@
/*
* Copyright (C) 2024-2025 OnixByte.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.onixbyte.jwt.constant;
/**
*
*/
public enum Algorithm {
HS256(1, 256, "HmacSHA256"),
HS384(1, 384, "HmacSHA384"),
HS512(1, 512, "HmacSHA512"),
RS256(2, 256, "SHA256withRSA"),
RS384(2, 384, "SHA384withRSA"),
RS512(2, 512, "SHA512withRSA"),
ES256(3, 256, "SHA256withECDSA"),
ES384(3, 384, "SHA384withECDSA"),
ES512(3, 512, "SHA512withECDSA");
/**
*
*/
private static final int HS_FLAG = 1; // 001
/**
*
*/
private static final int RS_FLAG = 2; // 010
/**
*
*/
private static final int ES_FLAG = 3; // 011
/**
*
*/
private final int typeFlag;
private final int shaLength;
private final String algorithm;
/**
*
* @param typeFlag
* @param shaLength
* @param algorithm
*/
Algorithm(int typeFlag, int shaLength, String algorithm) {
this.typeFlag = typeFlag;
this.shaLength = shaLength;
this.algorithm = algorithm;
}
/**
*
* @return
*/
public boolean isHmac() {
return (this.typeFlag & HS_FLAG) != 0;
}
/**
*
* @return
*/
public boolean isRsa() {
return (this.typeFlag & RS_FLAG) != 0;
}
/**
*
* @return
*/
public boolean isEcdsa() {
return (this.typeFlag & ES_FLAG) != 0;
}
/**
*
* @return
*/
public int getShaLength() {
return shaLength;
}
/**
*
* @return
*/
public int getTypeFlag() {
return typeFlag;
}
/**
*
* @return
*/
public String getAlgorithm() {
return algorithm;
}
}
@@ -0,0 +1,121 @@
/*
* Copyright (C) 2024-2025 OnixByte.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.onixbyte.jwt.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.onixbyte.jwt.TokenCreator;
import com.onixbyte.jwt.TokenPayload;
import com.onixbyte.jwt.constant.Algorithm;
import com.onixbyte.jwt.constant.HeaderClaims;
import com.onixbyte.jwt.holder.ObjectMapperHolder;
import com.onixbyte.jwt.util.CryptoUtil;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
/**
* Implementation of {@link TokenCreator} that generates HMAC-signed JSON Web Tokens (JWTs).
* <p>
* This class uses a specified HMAC algorithm to create signed tokens, incorporating a header,
* payload, and signature. It ensures the secret key meets the minimum length requirement for
* the chosen algorithm and handles JSON serialisation of the token components.
*
* @author zihluwang
*/
public class HmacTokenCreator implements TokenCreator {
private final Algorithm algorithm;
private final String issuer;
private final byte[] secret;
private final ObjectMapper objectMapper;
/**
* Constructs an HMAC token creator with the specified algorithm, issuer, and secret key.
* <p>
* Validates that the secret key length meets the minimum requirement for the chosen algorithm.
*
* @param algorithm the HMAC algorithm to use for signing (e.g., HS256, HS384, HS512)
* @param issuer the issuer identifier to include in the token payload if not already present
* @param secret the secret key as a string, used to generate the HMAC signature
* @throws IllegalArgumentException if the secret key is shorter than the minimum required
* length for the specified algorithm
*/
public HmacTokenCreator(Algorithm algorithm, String issuer, String secret) {
var _minSecretLength = algorithm.getShaLength() >> 3;
var secretBytesLength = secret.getBytes(StandardCharsets.UTF_8).length;
if (secretBytesLength < _minSecretLength) {
throw new IllegalArgumentException("Secret key too short for HS%d: minimum %d bytes required, got %d."
.formatted(algorithm.getShaLength(), _minSecretLength, secretBytesLength)
);
}
this.algorithm = algorithm;
this.issuer = issuer;
this.secret = secret.getBytes(StandardCharsets.UTF_8);
this.objectMapper = ObjectMapperHolder.getInstance().getObjectMapper();
}
/**
* Creates and signs a JWT using the HMAC algorithm.
* <p>
* Generates a token by encoding the header and payload as Base64 URL-safe strings,
* creating an HMAC signature, and concatenating them with dots. If the payload does not
* include an issuer, the configured issuer is added.
*
* @param payload the {@link TokenPayload} containing claims to include in the token
* @return the signed JWT as a string in the format "header.payload.signature"
* @throws IllegalArgumentException if the payload cannot be serialised to JSON due to
* invalid data or structure
* @throws RuntimeException if an unexpected error occurs during JSON processing
*/
@Override
public String sign(TokenPayload payload) {
var header = new HashMap<String, String>();
header.put(HeaderClaims.ALGORITHM, algorithm.name());
if (!header.containsKey(HeaderClaims.TYPE)) {
header.put(HeaderClaims.TYPE, "JWT");
}
if (!payload.hasIssuer()) {
payload.withIssuer(issuer);
}
try {
var encodedHeader = Base64.getUrlEncoder().withoutPadding()
.encodeToString(objectMapper.writeValueAsBytes(header));
var encodedPayload = Base64.getUrlEncoder().withoutPadding()
.encodeToString(objectMapper.writeValueAsBytes(payload.getPayload()));
var signatureBytes = CryptoUtil.createSignatureFor(algorithm,
secret,
encodedHeader.getBytes(StandardCharsets.UTF_8),
encodedPayload.getBytes(StandardCharsets.UTF_8));
var signature = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString((signatureBytes));
return "%s.%s.%s".formatted(encodedHeader, encodedPayload, signature);
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to serialise token header or payload to JSON.", e);
}
}
}
@@ -0,0 +1,179 @@
/*
* Copyright (C) 2024-2025 OnixByte.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.onixbyte.jwt.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.onixbyte.jwt.TokenResolver;
import com.onixbyte.jwt.constant.Algorithm;
import com.onixbyte.jwt.constant.RegisteredClaims;
import com.onixbyte.jwt.data.RawTokenComponent;
import com.onixbyte.jwt.holder.ObjectMapperHolder;
import com.onixbyte.jwt.util.CryptoUtil;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Map;
/**
* Implementation of {@link TokenResolver} that resolves and verifies HMAC-signed JSON Web
* Tokens (JWTs).
* <p>
* This class splits a JWT into its components, verifies its signature using an HMAC algorithm, and
* deserialises the header and payload into usable data structures. It ensures the secret key meets
* the minimum length requirement for the specified algorithm.
*
* @author zihluwang
*/
public class HmacTokenResolver implements TokenResolver {
private final Algorithm algorithm;
private final byte[] secret;
private final ObjectMapper objectMapper;
/**
* Constructs an HMAC token resolver with the specified algorithm and secret key.
* <p>
* Validates that the secret key length meets the minimum requirement for the chosen algorithm.
*
* @param algorithm the HMAC algorithm used for signature verification (e.g., HS256,
* HS384, HS512)
* @param secret the secret key as a string, used to verify the HMAC signature
* @throws IllegalArgumentException if the secret key is shorter than the minimum required
* length for the specified algorithm
*/
public HmacTokenResolver(Algorithm algorithm, String secret) {
var _minSecretLength = algorithm.getShaLength() >> 3;
var secretBytesLength = secret.getBytes(StandardCharsets.UTF_8).length;
if (secretBytesLength < _minSecretLength) {
throw new IllegalArgumentException("Secret key too short for HS%d: minimum %d bytes required, got %d"
.formatted(algorithm.getShaLength(), _minSecretLength, secretBytesLength)
);
}
this.algorithm = algorithm;
this.secret = secret.getBytes(StandardCharsets.UTF_8);
this.objectMapper = ObjectMapperHolder.getInstance().getObjectMapper();
}
/**
* Splits a JWT into its raw components: header, payload, and signature.
*
* @param token the JWT string to split
* @return a {@link RawTokenComponent} containing the header, payload, and signature as strings
* @throws IllegalArgumentException if the token does not consist of exactly three parts
* separated by dots
*/
@Override
public RawTokenComponent splitToken(String token) {
var tokenTuple = token.split("\\.");
if (tokenTuple.length != 3) {
throw new IllegalArgumentException(
"The provided JWT is invalid: it must consist of exactly three parts separated by dots.");
}
return new RawTokenComponent(tokenTuple[0], tokenTuple[1], tokenTuple[2]);
}
/**
* Verifies the HMAC signature of the provided JWT.
* <p>
* Splits the token into its components and uses the configured algorithm and secret to check
* the signature's validity. If the signature does not match, an exception is thrown by the
* underlying cryptographic utility.
*
* @param token the JWT string to verify
* @throws IllegalArgumentException if the token is malformed or the signature verification
* fails due to an invalid algorithm, key, or
* mismatched signature
*/
@Override
public void verify(String token) {
var _token = splitToken(token);
var isValid = CryptoUtil.verifySignatureFor(algorithm,
secret,
_token.header(),
_token.payload(),
_token.signature().getBytes(StandardCharsets.UTF_8)
);
if (!isValid) throw new IllegalArgumentException(
"JWT signature verification failed: the token may be tampered with or invalid.");
}
/**
* Retrieves the header claims from the provided JWT.
* <p>
* Decodes the Base64-encoded header and deserialises it into a map of strings.
*
* @param token the JWT string from which to extract the header
* @return a map containing the header claims as key-value pairs
* @throws IllegalArgumentException if the token is malformed or the header cannot be
* deserialised due to invalid JSON format
*/
@Override
public Map<String, String> getHeader(String token) {
var _token = splitToken(token);
var headerBytes = Base64.getDecoder().decode(_token.header());
var headerJson = new String(headerBytes);
try {
return objectMapper.readValue(headerJson, new TypeReference<>() {
});
} catch (JsonProcessingException e) {
throw new IllegalArgumentException(
"Failed to deserialise JWT header: the header JSON is invalid or malformed.", e
);
}
}
/**
* Retrieves the payload claims from the provided JWT, excluding registered claims.
* <p>
* Decodes the Base64-encoded payload, deserialises it into a map, and removes any registered
* claims as defined in {@link RegisteredClaims}.
*
* @param token the JWT string from which to extract the payload
* @return a map containing the custom payload claims as key-value pairs
* @throws IllegalArgumentException if the token is malformed or the payload cannot be
* deserialised due to invalid JSON format
*/
@Override
public Map<String, Object> getPayload(String token) {
var _token = splitToken(token);
var payloadBytes = Base64.getDecoder().decode(_token.payload());
var payloadJson = new String(payloadBytes);
try {
var payloadMap = objectMapper.readValue(payloadJson, new TypeReference<Map<String, Object>>() {
});
payloadMap.keySet().removeIf(RegisteredClaims.VALUES::contains);
return payloadMap;
} catch (JsonProcessingException e) {
throw new IllegalArgumentException(
"Failed to deserialise JWT payload: the payload JSON is invalid or malformed.", e
);
}
}
}
@@ -0,0 +1,133 @@
/*
* Copyright (C) 2024-2025 OnixByte.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
*
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.onixbyte.jwt.util;
import com.onixbyte.jwt.constant.Algorithm;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* Utility class for cryptographic operations related to JWT processing.
* <p>
* Provides methods for creating and verifying signatures using specified algorithms, primarily for
* JSON Web Token (JWT) authentication purposes.
*
* @author zihluwang
*/
public final class CryptoUtil {
/**
* Private constructor to prevent instantiation of this utility class.
*/
private CryptoUtil() {
}
private static final byte JWT_PART_SEPARATOR = (byte) 46;
/**
* Creates a signature for the given header and payload using the specified algorithm
* and secret.
*
* @param algorithm the cryptographic algorithm to use (e.g., HMAC-SHA256)
* @param secret the secret key bytes used for signing
* @param header the header bytes to include in the signature
* @param payload the payload bytes to include in the signature
* @return the generated signature bytes
* @throws IllegalArgumentException if the algorithm is not supported or the key is invalid
*/
public static byte[] createSignatureFor(
Algorithm algorithm,
byte[] secret,
byte[] header,
byte[] payload) {
try {
final var mac = Mac.getInstance(algorithm.getAlgorithm());
mac.init(new SecretKeySpec(secret, algorithm.getAlgorithm()));
mac.update(header);
mac.update(JWT_PART_SEPARATOR);
return mac.doFinal(payload);
} catch (InvalidKeyException e) {
throw new IllegalArgumentException("The provided secret key is invalid for the algorithm '%s'."
.formatted(algorithm.getAlgorithm()), e);
} catch (NoSuchAlgorithmException e) {
throw new IllegalArgumentException("The specified algorithm '%s' is not supported."
.formatted(algorithm.getAlgorithm()), e);
}
}
/**
* Verifies the signature for the given header and payload using the specified algorithm
* and secret.
* <p>
* This method converts the header and payload strings to UTF-8 bytes before verification.
*
* @param algorithm the cryptographic algorithm used for signing
* @param secretBytes the secret key bytes used for signing
* @param header the header string to verify
* @param payload the payload string to verify
* @param signatureBytes the signature bytes to check against
* @return {@code true} if the signature is valid, {@code false} otherwise
* @throws IllegalArgumentException if the algorithm is not supported or the key is invalid
*/
public static boolean verifySignatureFor(
Algorithm algorithm,
byte[] secretBytes,
String header,
String payload,
byte[] signatureBytes) {
return verifySignatureFor(
algorithm,
secretBytes,
header.getBytes(StandardCharsets.UTF_8),
payload.getBytes(StandardCharsets.UTF_8),
signatureBytes);
}
/**
* Verifies the signature for the given header and payload bytes using the specified algorithm
* and secret.
*
* @param algorithm the cryptographic algorithm used for signing
* @param secretBytes the secret key bytes used for signing
* @param headerBytes the header bytes to verify
* @param payloadBytes the payload bytes to verify
* @param signatureBytes the signature bytes to check against
* @return {@code true} if the signature matches, {@code false} otherwise
* @throws IllegalArgumentException if the algorithm is not supported or the key is invalid
*/
public static boolean verifySignatureFor(
Algorithm algorithm,
byte[] secretBytes,
byte[] headerBytes,
byte[] payloadBytes,
byte[] signatureBytes) {
return MessageDigest.isEqual(
createSignatureFor(
algorithm,
secretBytes,
headerBytes,
payloadBytes),
signatureBytes);
}
}