[AMQ-9244] Add JWT authentication plugin

This commit is contained in:
JB Onofré 2023-06-19 18:20:42 +02:00
parent 52d70325ca
commit 8103ad959a
5 changed files with 512 additions and 0 deletions

View File

@ -66,6 +66,18 @@
<artifactId>activemq-jaas</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.22</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.bitbucket.b_c</groupId>
<artifactId>jose4j</artifactId>
<version>0.7.9</version>
<optional>true</optional>
</dependency>
<!-- =============================== -->
<!-- Testing Dependencies -->

View File

@ -0,0 +1,104 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.activemq.security.jwt;
import com.fasterxml.jackson.annotation.JsonValue;
import java.util.Set;
public enum Claims {
iss("Issuer", String.class),
sub("Subject", String.class),
exp("Expiration Time", Long.class),
iat("Issued At Time", Long.class),
jti("JWT ID", String.class),
upn("JWT specific unique principal name", String.class),
groups("JWT specific groups permission grant", Set.class),
raw_token("JWT specific original bearer token", String.class),
aud("Audience", Set.class),
nbf("Not Before", Long.class),
auth_time("Time when the authentication occurred", Long.class),
updated_at("Time the information was last updated", Long.class),
azp("Authorized party - the party to which the ID Token was issued", String.class),
nonce("Value used to associate a Client session with an ID Token", String.class),
at_hash("Access Token hash value", Long.class),
c_hash("Code hash value", Long.class),
full_name("Full name", String.class),
family_name("Surname(s) or last name(s)", String.class),
middle_name("Middle name(s)", String.class),
nickname("Casual name", String.class),
given_name("Given name(s) or first name(s)", String.class),
preferred_username("Shorthand name by which the End-User wishes to be referred to", String.class),
email("Preferred e-mail address", String.class),
email_verified("True if the e-mail address has been verified; otherwise false", Boolean.class),
gender("Gender", String.class),
birthdate("Birthday", String.class),
zoneinfo("Time zone", String.class),
locale("Locale", String.class),
phone_number("Preferred telephone number", String.class),
phone_number_verified("True if the phone number has been verified; otherwise false", Boolean.class),
address("Preferred postal address", JsonValue.class),
acr("Authentication Context Class Reference", String.class),
amr("Authentication Methods References", String.class),
sub_jwk("Public key used to check the signature of an ID Token", JsonValue.class),
cnf("Confirmation", String.class),
sip_from_tag("SIP From tag header field parameter value", String.class),
sip_date("SIP Date header field value", String.class),
sip_callid("SIP Call-Id header field value", String.class),
sip_cseq_num("SIP CSeq numery header field parameter value", String.class),
sip_via_branch("SIP Via branch header field parameter value", String.class),
orig("Originating Identity String", String.class),
dest("Destination Identity String", String.class),
mky("Media Key Fingerprint String", String.class),
jwk("JSON Web Key Representing Public Key", JsonValue.class),
jwe("Encrypted JSON Web Key", String.class),
kid("Key identifier", String.class),
jku("JWK Set URL", String.class),
UNKNOWN("A catch all for any unknown claim", Void.class)
;
// @formatter:on
private String description;
private Class<?> type;
Claims(final String description, final Class<?> type) {
this.description = description;
this.type = type;
}
/**
* @return A description for the claim
*/
public String getDescription() {
return description;
}
/**
* The required type of the claim
*
* @return type of the claim
*/
public Class<?> getType() {
return type;
}
}

View File

@ -0,0 +1,179 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.activemq.security.jwt;
import org.apache.activemq.broker.Broker;
import org.apache.activemq.broker.ConnectionContext;
import org.apache.activemq.command.ConnectionInfo;
import org.apache.activemq.jaas.GroupPrincipal;
import org.apache.activemq.security.AbstractAuthenticationBroker;
import org.apache.activemq.security.SecurityContext;
import org.jose4j.jwa.AlgorithmConstraints;
import org.jose4j.jws.AlgorithmIdentifiers;
import org.jose4j.jwt.JwtClaims;
import org.jose4j.jwt.MalformedClaimException;
import org.jose4j.jwt.consumer.InvalidJwtException;
import org.jose4j.jwt.consumer.JwtConsumer;
import org.jose4j.jwt.consumer.JwtConsumerBuilder;
import org.jose4j.jwt.consumer.JwtContext;
import java.security.Key;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.Principal;
import java.security.cert.X509Certificate;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Handle authentication an user based on a JWT token
*/
public class JwtAuthenticationBroker extends AbstractAuthenticationBroker {
private final String jwtIssuer;
private final Claims jwtGroupsClaim;
private final String jwtValidatingPublicKey;
public JwtAuthenticationBroker(
final Broker next,
final String jwtIssuer,
final Claims jwtGroupsClaim,
final String jwtValidatingPublicKey) {
super(next);
this.jwtIssuer = jwtIssuer;
this.jwtGroupsClaim = jwtGroupsClaim;
this.jwtValidatingPublicKey = jwtValidatingPublicKey;
}
@Override
public void addConnection(final ConnectionContext context, final ConnectionInfo info) throws Exception {
SecurityContext securityContext = context.getSecurityContext();
if (securityContext == null) {
securityContext = authenticate(info.getUserName(), info.getPassword(), null);
context.setSecurityContext(securityContext);
securityContexts.add(securityContext);
}
try {
super.addConnection(context, info);
} catch (Exception e) {
securityContexts.remove(securityContext);
context.setSecurityContext(null);
throw e;
}
}
@Override
public SecurityContext authenticate(final String username, final String password, final X509Certificate[] certificates) throws SecurityException {
SecurityContext securityContext = null;
if (!username.isEmpty()) {
// parse the JWT token and check signature, validity, nbf
try {
final JwtConsumerBuilder builder = new JwtConsumerBuilder()
.setRelaxVerificationKeyValidation()
.setRequireSubject()
.setSkipDefaultAudienceValidation()
.setRequireExpirationTime()
.setExpectedIssuer(jwtIssuer)
.setAllowedClockSkewInSeconds(5)
.setVerificationKey(parsePCKS8(jwtValidatingPublicKey))
.setJwsAlgorithmConstraints(
new AlgorithmConstraints(AlgorithmConstraints.ConstraintType.WHITELIST,
AlgorithmIdentifiers.RSA_USING_SHA256,
AlgorithmIdentifiers.RSA_USING_SHA384,
AlgorithmIdentifiers.RSA_USING_SHA512)
);
final JwtConsumer jwtConsumer = builder.build();
final JwtContext jwtContext = jwtConsumer.process(username);
// validate the JWT and process it to the claims
jwtConsumer.processContext(jwtContext);
final JwtClaims claimsSet = jwtContext.getJwtClaims();
// we have to determine the unique name to use as the principal name. It comes from upn, preferred_username, sub in that order
String principalName = claimsSet.getClaimValue(Claims.upn.name(), String.class);
if (principalName == null) {
principalName = claimsSet.getClaimValue(Claims.preferred_username.name(), String.class);
if (principalName == null) {
principalName = claimsSet.getSubject();
}
}
final Set<String> groups = new HashSet<>();
final List<String> globalGroups = claimsSet.getStringListClaimValue(jwtGroupsClaim.name());
if (globalGroups != null) {
groups.addAll(globalGroups);
}
securityContext = new SecurityContext(principalName) {
@Override
public Set<Principal> getPrincipals() {
return groups.stream().map(GroupPrincipal::new).collect(Collectors.toSet());
}
};
} catch (final InvalidJwtException e) {
throw new RuntimeException("Failed to verify token", e);
} catch (final MalformedClaimException e) {
throw new RuntimeException("Failed to verify token claims", e);
}
} else {
// login as anonymous without any group or fail
// or whatever logic you should apply when no credentials are available
securityContext = new SecurityContext("anonymous") {
@Override
public Set<Principal> getPrincipals() {
return Collections.emptySet();
}
};
}
return securityContext;
}
private Key parsePCKS8(final String publicKey) {
try {
final X509EncodedKeySpec spec = new X509EncodedKeySpec(normalizeAndDecodePCKS8(publicKey));
final KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePublic(spec);
} catch (final NoSuchAlgorithmException | InvalidKeySpecException | IllegalArgumentException e) {
return null;
}
}
private byte[] normalizeAndDecodePCKS8(final String publicKey) {
if (publicKey.contains("PRIVATE KEY")) {
throw new RuntimeException("Public Key is Private.");
}
final String normalizedKey =
publicKey.replaceAll("-----BEGIN (.*)-----", "")
.replaceAll("-----END (.*)----", "")
.replaceAll("\r\n", "")
.replaceAll("\n", "");
return Base64.getDecoder().decode(normalizedKey);
}
}

View File

@ -0,0 +1,60 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.activemq.security.jwt;
import com.nimbusds.jose.JWSAlgorithm;
import org.apache.activemq.broker.Broker;
import org.apache.activemq.broker.BrokerPlugin;
/**
* A simple JWT plugin giving ActiveMQ the ability to support JWT tokens to authenticate and authorize users
*
* @org.apache.xbean.XBean element="jwtAuthenticationPlugin"
* description="Provides a JWT authentication plugin"
*
*
* This plugin is rather simple and is only meant to be used as a first step. In real world applications, we would need
* to being able to specify different key format (RSA, DSA, EC, etc), the issuer, the claim to use for groups and maybe
* some mapping functionalities.
* The header name is also hard coded for simplicity.
*/
public class JwtAuthenticationPlugin implements BrokerPlugin {
public static final String JWT_ISSUER = "https://server.example.com";
public static final Claims JWT_GROUPS_CLAIM = Claims.groups;
public static final String JWT_SIGNING_KEY_LOCATION = "/privateKey.pem";
public static final String JWT_VALIDATING_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlivFI8qB4D0y2jy0" +
"CfEqFyy46R0o7S8TKpsx5xbHKoU1VWg6QkQm+ntyIv1p4kE1sPEQO73+HY8+" +
"Bzs75XwRTYL1BmR1w8J5hmjVWjc6R2BTBGAYRPFRhor3kpM6ni2SPmNNhurE" +
"AHw7TaqszP5eUF/F9+KEBWkwVta+PZ37bwqSE4sCb1soZFrVz/UT/LF4tYpu" +
"VYt3YbqToZ3pZOZ9AX2o1GCG3xwOjkc4x0W7ezbQZdC9iftPxVHR8irOijJR" +
"RjcPDtA6vPKpzLl6CyYnsIYPd99ltwxTHjr3npfv/3Lw50bAkbT4HeLFxTx4" +
"flEoZLKO/g0bAoV2uqBhkA9xnQIDAQAB";
public static final JWSAlgorithm JWT_SIGNING_ALGO = JWSAlgorithm.RS256;
public static final String JWT_HEADER = "Authorization";
public JwtAuthenticationPlugin() {
}
public Broker installPlugin(final Broker next) {
// the public key is hard coded here for the sake of the example
return new JwtAuthenticationBroker(next, JWT_ISSUER, JWT_GROUPS_CLAIM, JWT_VALIDATING_PUBLIC_KEY);
}
}

View File

@ -0,0 +1,157 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You 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 org.apache.activemq.security.jwt;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.crypto.RSASSASigner;
import com.nimbusds.jose.shaded.json.JSONObject;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import java.io.InputStream;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
import java.util.List;
/**
* Utilities for generating a JWT for testing
*/
public class TokenUtils {
private TokenUtils() {}
/**
* Read a PEM encoded private key from the classpath.
*
* @param pemResName key file resource name
* @return the PrivateKey
* @throws Exception on decode failure
*/
public static PrivateKey readPrivateKey(String pemResName) throws Exception {
InputStream contentIS = TokenUtils.class.getResourceAsStream(pemResName);
byte[] tmp = new byte[4096];
int length = contentIS.read(tmp);
return decodePrivateKey(new String(tmp, 0, length));
}
/**
* Read a PEM encoded public key from the classpath.
*
* @param pemResName key file resource name
* @return the PublicKey
* @throws Exception on decode failure
*/
public static PublicKey readPublicKey(String pemResName) throws Exception {
InputStream contentIS = TokenUtils.class.getResourceAsStream(pemResName);
byte[] tmp = new byte[4096];
int length = contentIS.read(tmp);
return decodePublicKey(new String(tmp, 0, length));
}
/**
* Generate a new RSA keypair.
*
* @param keySize the size of the key
* @return the KeyPair
* @throws NoSuchAlgorithmException on failure to load RSA key generator
*/
public static KeyPair generateKeyPair(int keySize) throws NoSuchAlgorithmException {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(keySize);
return keyPairGenerator.genKeyPair();
}
/**
* Decode a PEM encoded private key string to a RSA PrivateKey.
*
* @param pemEncoded PEM string for private key
* @return the PrivateKey
* @throws Exception on decode failure
*/
public static PrivateKey decodePrivateKey(String pemEncoded) throws Exception {
pemEncoded = removeBeginEnd(pemEncoded);
byte[] pkcs8EncodedBytes = Base64.getDecoder().decode(pemEncoded);
// extract the private key
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8EncodedBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePrivate(keySpec);
}
/**
* Decode a PEM encoded public key string to a RSA PublicKey
*
* @param pemEncoded PEM string for public key
* @return the PublicKey
* @throws Exception on decode failure
*/
public static PublicKey decodePublicKey(String pemEncoded) throws Exception {
pemEncoded = removeBeginEnd(pemEncoded);
byte[] encodedBytes = Base64.getDecoder().decode(pemEncoded);
// extract the public key
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(encodedBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
return kf.generatePublic(keySpec);
}
private static String removeBeginEnd(String pem) {
pem = pem.replaceAll("-----BEGIN (.*)-----", "");
pem = pem.replaceAll("-----END (.*)----", "");
pem = pem.replaceAll("\r\n", "");
pem = pem.replaceAll("\n", "");
return pem.trim();
}
public static String token(final String name, final List<String> groups) {
final JSONObject claims = new JSONObject();
claims.put(Claims.iss.name(), JwtAuthenticationPlugin.JWT_ISSUER);
long currentTimeInSecs = System.currentTimeMillis() / 1000;
claims.put(Claims.iat.name(), currentTimeInSecs);
claims.put(Claims.auth_time.name(), currentTimeInSecs);
claims.put(Claims.exp.name(), currentTimeInSecs + 300);
claims.put(Claims.jti.name(), "a-123");
claims.put(Claims.sub.name(), "24400320");
claims.put(Claims.preferred_username.name(), name);
claims.put(Claims.aud.name(), "s6BhdRkqt3");
claims.put(Claims.groups.name(), groups);
try {
final PrivateKey pk = readPrivateKey(JwtAuthenticationPlugin.JWT_SIGNING_KEY_LOCATION);
final JWSHeader header = new JWSHeader.Builder(JwtAuthenticationPlugin.JWT_SIGNING_ALGO)
.keyID(JwtAuthenticationPlugin.JWT_SIGNING_KEY_LOCATION)
.type(JOSEObjectType.JWT)
.build();
final JWTClaimsSet claimsSet = JWTClaimsSet.parse(claims);
final SignedJWT jwt = new SignedJWT(header, claimsSet);
jwt.sign(new RSASSASigner(pk));
return jwt.serialize();
} catch (final Exception e) {
throw new RuntimeException("Could not sign JWT");
}
}
}