diff --git a/artemis-server-osgi/pom.xml b/artemis-server-osgi/pom.xml index d86b0c255b..27c867ed90 100644 --- a/artemis-server-osgi/pom.xml +++ b/artemis-server-osgi/pom.xml @@ -135,6 +135,7 @@ org.glassfish.json*;resolution:=optional, org.postgresql*;resolution:=optional, io.netty.buffer;io.netty.*;version="[4.1,5)", + java.net.http*;resolution:=optional, * <_exportcontents>org.apache.activemq.artemis.*;-noimport:=true diff --git a/artemis-server/pom.xml b/artemis-server/pom.xml index 22914df525..bb8c64b1b9 100644 --- a/artemis-server/pom.xml +++ b/artemis-server/pom.xml @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. --> - + 4.0.0 @@ -263,6 +264,12 @@ jakarta.json-api test + + org.mock-server + mockserver-netty + ${mockserver.version} + test + diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/KubernetesLoginModule.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/KubernetesLoginModule.java new file mode 100644 index 0000000000..5a50952e86 --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/KubernetesLoginModule.java @@ -0,0 +1,166 @@ +/* + * 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.artemis.spi.core.security.jaas; + +import java.io.IOException; +import java.security.Principal; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import javax.security.auth.Subject; +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; +import javax.security.auth.login.FailedLoginException; +import javax.security.auth.login.LoginException; + +import org.apache.activemq.artemis.logs.AuditLogger; +import org.apache.activemq.artemis.spi.core.security.jaas.kubernetes.client.KubernetesClient; +import org.apache.activemq.artemis.spi.core.security.jaas.kubernetes.client.KubernetesClientImpl; +import org.apache.activemq.artemis.spi.core.security.jaas.kubernetes.model.TokenReview; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class KubernetesLoginModule extends PropertiesLoader implements AuditLoginModule { + + private static final Logger logger = LoggerFactory.getLogger(KubernetesLoginModule.class); + + public static final String K8S_ROLE_FILE_PROP_NAME = "org.apache.activemq.jaas.kubernetes.role"; + + private CallbackHandler handler; + private Subject subject; + private TokenReview tokenReview = new TokenReview(); + private Map> roles; + private final Set principals = new HashSet<>(); + private final KubernetesClient client; + + public KubernetesLoginModule(KubernetesClient client) { + this.client = client; + } + + public KubernetesLoginModule() { + this(new KubernetesClientImpl()); + } + + @Override + public void initialize(Subject subject, CallbackHandler callbackHandler, Map sharedState, + Map options) { + this.handler = callbackHandler; + this.subject = subject; + + debug = booleanOption("debug", options); + if (debug) { + logger.debug("Initialized debug"); + } + roles = load(K8S_ROLE_FILE_PROP_NAME, "k8s-roles.properties", options).invertedPropertiesValuesMap(); + if (debug) { + logger.debug("loaded roles: {}", roles); + } + } + + @Override + public boolean login() throws LoginException { + Callback[] callbacks = new Callback[1]; + callbacks[0] = new PasswordCallback("Password", false); + + try { + handler.handle(callbacks); + } catch (IOException | UnsupportedCallbackException e) { + throw (LoginException) new LoginException().initCause(e); + } + + char[] token = ((PasswordCallback) callbacks[0]).getPassword(); + + if (token.length == 0) { + throw new FailedLoginException("Bearer token is empty"); + } + + tokenReview = client.getTokenReview(new String(token)); + + if (debug) { + logger.debug("login {}", tokenReview); + } + return tokenReview.isAuthenticated(); + } + + @Override + public boolean commit() throws LoginException { + boolean result = false; + result = tokenReview.isAuthenticated(); + + Set authenticatedUsers = subject.getPrincipals(UserPrincipal.class); + if (result) { + UserPrincipal userPrincipal = new ServiceAccountPrincipal(tokenReview.getUsername()); + principals.add(userPrincipal); + authenticatedUsers.add(userPrincipal); + } + // populate roles for UserPrincipal from other login modules too + for (UserPrincipal userPrincipal : authenticatedUsers) { + Set matchedRoles = roles.get(userPrincipal.getName()); + if (matchedRoles != null) { + for (String entry : matchedRoles) { + principals.add(new RolePrincipal(entry)); + } + } + } + + subject.getPrincipals().addAll(principals); + + clear(); + + if (debug) { + logger.debug("commit, result: {}, principals: {}", result, principals); + } + return result; + } + + @Override + public boolean abort() throws LoginException { + registerFailureForAudit(tokenReview.getUsername()); + clear(); + + if (debug) { + logger.debug("abort"); + } + return true; + } + + @Override + public void registerFailureForAudit(String name) { + Subject subject = new Subject(); + subject.getPrincipals().add(new ServiceAccountPrincipal(name)); + AuditLogger.setCurrentCaller(subject); + } + + @Override + public boolean logout() throws LoginException { + subject.getPrincipals().removeAll(principals); + principals.clear(); + clear(); + if (debug) { + logger.debug("logout"); + } + return true; + } + + private void clear() { + tokenReview = new TokenReview(); + } + +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/PropertiesLoader.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/PropertiesLoader.java index 80adf23ca4..6adf4dca41 100644 --- a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/PropertiesLoader.java +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/PropertiesLoader.java @@ -54,7 +54,7 @@ public class PropertiesLoader { return result.obtained(); } - private static boolean booleanOption(String name, Map options) { + protected static boolean booleanOption(String name, Map options) { return Boolean.parseBoolean((String) options.get(name)); } diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/ServiceAccountPrincipal.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/ServiceAccountPrincipal.java new file mode 100644 index 0000000000..757dc45caa --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/ServiceAccountPrincipal.java @@ -0,0 +1,46 @@ +/* + * 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.artemis.spi.core.security.jaas; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class ServiceAccountPrincipal extends UserPrincipal { + + private static final Pattern SA_NAME_PATTERN = Pattern.compile("system:serviceaccounts:([\\w-]+):([\\w-]+)"); + + private String saName; + private String namespace; + + public ServiceAccountPrincipal(String name) { + super(name); + Matcher matcher = SA_NAME_PATTERN.matcher(name); + if (matcher.find()) { + namespace = matcher.group(1); + saName = matcher.group(2); + } + } + + public String getSaName() { + return saName; + } + + public String getNamespace() { + return namespace; + } + +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/client/KubernetesClient.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/client/KubernetesClient.java new file mode 100644 index 0000000000..fd0b8877df --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/client/KubernetesClient.java @@ -0,0 +1,25 @@ +/* + * 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.artemis.spi.core.security.jaas.kubernetes.client; + +import org.apache.activemq.artemis.spi.core.security.jaas.kubernetes.model.TokenReview; + +public interface KubernetesClient { + + TokenReview getTokenReview(String token); + +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/client/KubernetesClientImpl.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/client/KubernetesClientImpl.java new file mode 100644 index 0000000000..6172bcd15a --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/client/KubernetesClientImpl.java @@ -0,0 +1,173 @@ +/* + * 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.artemis.spi.core.security.jaas.kubernetes.client; + +import static java.net.HttpURLConnection.HTTP_CREATED; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandlers; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.Scanner; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +import org.apache.activemq.artemis.spi.core.security.jaas.kubernetes.model.TokenReview; +import org.apache.activemq.artemis.utils.JsonLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class KubernetesClientImpl implements KubernetesClient { + + private static final Logger logger = LoggerFactory.getLogger(KubernetesClientImpl.class); + + private static final String KUBERNETES_HOST = "KUBERNETES_SERVICE_HOST"; + private static final String KUBERNETES_PORT = "KUBERNETES_SERVICE_PORT"; + private static final String KUBERNETES_TOKEN_PATH = "KUBERNETES_TOKEN_PATH"; + private static final String KUBERNETES_CA_PATH = "KUBERNETES_CA_PATH"; + + private static final String KUBERNETES_TOKENREVIEW_URI_PATTERN = "https://%s:%s/apis/authentication.k8s.io/v1/tokenreviews"; + + private static final String DEFAULT_KUBERNETES_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"; + private static final String DEFAULT_KUBERNETES_CA_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"; + + private URI apiUri; + private String tokenPath; + private String caPath; + + public KubernetesClientImpl() { + this.tokenPath = getParam(KUBERNETES_TOKEN_PATH, DEFAULT_KUBERNETES_TOKEN_PATH); + this.caPath = getParam(KUBERNETES_CA_PATH, DEFAULT_KUBERNETES_CA_PATH); + String host = getParam(KUBERNETES_HOST); + String port = getParam(KUBERNETES_PORT); + this.apiUri = URI.create(String.format(KUBERNETES_TOKENREVIEW_URI_PATTERN, host, port)); + } + + private String getParam(String name, String defaultValue) { + String value = System.getenv(name); + if (value == null) { + value = System.getProperty(name, defaultValue); + } + if (value == null) { + return defaultValue; + } + return value; + } + + private String getParam(String name) { + return getParam(name, null); + } + + @Override + public TokenReview getTokenReview(String token) { + TokenReview tokenReview = new TokenReview(); + String authToken = null; + try { + logger.debug("Loading client authentication token from {}", tokenPath); + authToken = readFile(tokenPath); + logger.debug("Loaded client authentication token from {}", tokenPath); + } catch (IOException e) { + logger.error("Cannot retrieve Service Account Authentication Token from " + tokenPath, e); + return tokenReview; + } + String jsonRequest = buildJsonRequest(token); + + SSLContext ctx; + try { + ctx = buildSSLContext(); + } catch (Exception e) { + logger.error("Unable to build a valid SSLContext", e); + return tokenReview; + } + HttpClient client = HttpClient.newBuilder().sslContext(ctx).build(); + + HttpRequest request = HttpRequest.newBuilder(apiUri) + .header("Authorization", "Bearer " + authToken) + .header("Accept", "application/json; charset=utf-8") + .POST(HttpRequest.BodyPublishers.ofString(jsonRequest)).build(); + logger.debug("Submit TokenReview request to Kubernetes API"); + + try { + HttpResponse response = client.send(request, BodyHandlers.ofString()); + if (response.statusCode() == HTTP_CREATED) { + logger.debug("Received valid TokenReview response"); + return TokenReview.fromJsonString(response.body()); + } + logger.error("Unable to retrieve a valid TokenReview. Received StatusCode: {}. Body: {}", + response.statusCode(), response.body()); + } catch (IOException | InterruptedException e) { + logger.error("Unable to request ReviewToken", e); + } + return tokenReview; + } + + private String readFile(String path) throws IOException { + try (Scanner scanner = new Scanner(Path.of(path))) { + StringBuilder buffer = new StringBuilder(); + while (scanner.hasNextLine()) { + String line = scanner.nextLine(); + if (!line.isBlank() && !line.startsWith("#")) { + buffer.append(line); + } + } + return buffer.toString(); + } + } + + private String buildJsonRequest(String clientToken) { + return JsonLoader.createObjectBuilder() + .add("apiVersion", "authentication.k8s.io/v1") + .add("kind", "TokenReview") + .add("spec", JsonLoader.createObjectBuilder() + .add("token", clientToken) + .build()) + .build().toString(); + } + + private SSLContext buildSSLContext() throws Exception { + SSLContext ctx = SSLContext.getInstance("SSL"); + File certFile = new File(caPath); + if (!certFile.exists()) { + logger.debug("Kubernetes CA certificate not found at: {}. Truststore not configured", caPath); + return ctx; + } + try (InputStream fis = new FileInputStream(certFile)) { + KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType()); + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + X509Certificate certificate = (X509Certificate) certFactory.generateCertificate(fis); + trustStore.load(null, null); + trustStore.setCertificateEntry(certFile.getName(), certificate); + TrustManagerFactory tmFactory = TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmFactory.init(trustStore); + + ctx.init(null, tmFactory.getTrustManagers(), new SecureRandom()); + } + return ctx; + } +} diff --git a/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/model/TokenReview.java b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/model/TokenReview.java new file mode 100644 index 0000000000..2b3d6bed7a --- /dev/null +++ b/artemis-server/src/main/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/model/TokenReview.java @@ -0,0 +1,136 @@ +/* + * 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.artemis.spi.core.security.jaas.kubernetes.model; + +import java.io.StringReader; +import java.util.Collections; +import java.util.List; + +import org.apache.activemq.artemis.json.JsonArray; +import org.apache.activemq.artemis.json.JsonObject; +import org.apache.activemq.artemis.json.JsonString; +import org.apache.activemq.artemis.utils.JsonLoader; + +public class TokenReview { + + private boolean authenticated; + private User user; + private List audiences; + + public boolean isAuthenticated() { + return authenticated; + } + + public User getUser() { + return user; + } + + public String getUsername() { + if (user == null) { + return null; + } + return user.getUsername(); + } + + public List getAudiences() { + return audiences; + } + + public static TokenReview fromJsonString(String obj) { + JsonObject json = JsonLoader.readObject(new StringReader(obj)); + JsonObject status = json.getJsonObject("status"); + return TokenReview.fromJson(status); + } + + private static TokenReview fromJson(JsonObject obj) { + TokenReview t = new TokenReview(); + if (obj == null) { + return t; + } + t.authenticated = obj.getBoolean("authenticated", false); + t.user = User.fromJson(obj.getJsonObject("user")); + t.audiences = listFromJson(obj.getJsonArray("audiences")); + return t; + } + + private static List listFromJson(JsonArray items) { + if (items == null) { + return Collections.emptyList(); + } + return Collections.unmodifiableList(items.getValuesAs(JsonString::getString)); + } + + public static class User { + + private String username; + private String uid; + private List groups; + private Extra extra; + + public Extra getExtra() { + return extra; + } + + public List getGroups() { + return groups; + } + + public String getUid() { + return uid; + } + + public String getUsername() { + return username; + } + + public static User fromJson(JsonObject obj) { + if (obj == null) { + return null; + } + User u = new User(); + u.username = obj.getString("username", null); + u.uid = obj.getString("uid", null); + u.groups = listFromJson(obj.getJsonArray("groups")); + u.extra = Extra.fromJson(obj.getJsonObject("extra")); + return u; + } + + } + + public static class Extra { + private List podNames; + private List podUids; + + public List getPodNames() { + return podNames; + } + + public List getPodUids() { + return podUids; + } + + public static Extra fromJson(JsonObject obj) { + if (obj == null) { + return null; + } + Extra e = new Extra(); + e.podNames = listFromJson(obj.getJsonArray("authentication.kubernetes.io/pod-name")); + e.podUids = listFromJson(obj.getJsonArray("authentication.kubernetes.io/pod-uid")); + return e; + } + } +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/KubernetesLoginModuleTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/KubernetesLoginModuleTest.java new file mode 100644 index 0000000000..be1aede8ea --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/KubernetesLoginModuleTest.java @@ -0,0 +1,144 @@ +/* + * 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.artemis.spi.core.security.jaas; + +import static org.apache.activemq.artemis.spi.core.security.jaas.KubernetesLoginModule.K8S_ROLE_FILE_PROP_NAME; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import javax.security.auth.Subject; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.login.LoginException; + +import org.apache.activemq.artemis.spi.core.security.jaas.kubernetes.TokenCallbackHandler; +import org.apache.activemq.artemis.spi.core.security.jaas.kubernetes.client.KubernetesClient; +import org.apache.activemq.artemis.spi.core.security.jaas.kubernetes.model.TokenReview; +import org.junit.Test; + +public class KubernetesLoginModuleTest { + + private final KubernetesClient client = mock(KubernetesClient.class); + private final KubernetesLoginModule loginModule = new KubernetesLoginModule(client); + private static final String TOKEN = "the_token"; + + public static final String USERNAME = "system:serviceaccounts:some-ns:kermit"; + public static final String AUTH_JSON = "{\"status\": {" + + "\"authenticated\": true, " + + "\"user\": {" + + " \"username\": \"" + USERNAME + "\"" + + "}}}"; + + public static final String UNAUTH_JSON = "{\"status\": {" + + "\"authenticated\": false " + + "}}"; + + @Test + public void testBasicLogin() throws LoginException { + CallbackHandler handler = new TokenCallbackHandler(TOKEN); + Subject subject = new Subject(); + loginModule.initialize(subject, handler, Collections.emptyMap(), getDefaultOptions()); + + TokenReview tr = TokenReview.fromJsonString(AUTH_JSON); + when(client.getTokenReview(TOKEN)).thenReturn(tr); + + assertTrue(loginModule.login()); + assertTrue(loginModule.commit()); + + assertThat(subject.getPrincipals(UserPrincipal.class), hasSize(1)); + subject.getPrincipals(ServiceAccountPrincipal.class).forEach(p -> { + assertThat(p.getName(), is(USERNAME)); + assertThat(p.getSaName(), is("kermit")); + assertThat(p.getNamespace(), is("some-ns")); + }); + Set roles = subject.getPrincipals(RolePrincipal.class); + assertThat(roles, hasSize(2)); + assertThat(roles, containsInAnyOrder(new RolePrincipal("muppet"), new RolePrincipal("admin"))); + + assertTrue(loginModule.logout()); + assertFalse(loginModule.commit()); + assertThat(subject.getPrincipals(), empty()); + verify(client, times(1)).getTokenReview(TOKEN); + } + + @Test + public void testFailedLogin() throws LoginException { + CallbackHandler handler = new TokenCallbackHandler(TOKEN); + Subject subject = new Subject(); + loginModule.initialize(subject, handler, Collections.emptyMap(), getDefaultOptions()); + + TokenReview tr = TokenReview.fromJsonString(UNAUTH_JSON); + when(client.getTokenReview(TOKEN)).thenReturn(tr); + + assertFalse(loginModule.login()); + assertFalse(loginModule.commit()); + assertThat(subject.getPrincipals(), empty()); + verify(client, times(1)).getTokenReview(TOKEN); + } + + @Test + public void testNullToken() throws LoginException { + CallbackHandler handler = new TokenCallbackHandler(null); + Subject subject = new Subject(); + loginModule.initialize(subject, handler, Collections.emptyMap(), getDefaultOptions()); + + try { + assertFalse(loginModule.login()); + fail("Exception expected"); + } catch (LoginException e) { + assertNotNull(e); + } + + assertFalse(loginModule.commit()); + assertThat(subject.getPrincipals(), empty()); + verifyNoInteractions(client); + } + + @Test + public void testUnableToVerifyToken() throws LoginException { + CallbackHandler handler = new TokenCallbackHandler(TOKEN); + Subject subject = new Subject(); + loginModule.initialize(subject, handler, Collections.emptyMap(), getDefaultOptions()); + + when(client.getTokenReview(TOKEN)).thenReturn(new TokenReview()); + + assertFalse(loginModule.login()); + assertFalse(loginModule.commit()); + assertThat(subject.getPrincipals(), empty()); + verify(client, times(1)).getTokenReview(TOKEN); + } + + private Map getDefaultOptions() { + return Map.of(K8S_ROLE_FILE_PROP_NAME, + "k8s-roles.properties"); + } +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/TokenCallbackHandler.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/TokenCallbackHandler.java new file mode 100644 index 0000000000..a13e89c7f0 --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/TokenCallbackHandler.java @@ -0,0 +1,48 @@ +/* + * 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.artemis.spi.core.security.jaas.kubernetes; + +import java.io.IOException; + +import javax.security.auth.callback.Callback; +import javax.security.auth.callback.CallbackHandler; +import javax.security.auth.callback.PasswordCallback; +import javax.security.auth.callback.UnsupportedCallbackException; + +public class TokenCallbackHandler implements CallbackHandler { + + private final char[] password; + + public TokenCallbackHandler(String password) { + if (password != null) { + this.password = password.toCharArray(); + } else { + this.password = new char[0]; + } + } + + @Override + public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException { + for (Callback c : callbacks) { + if (c instanceof PasswordCallback) { + ((PasswordCallback) c).setPassword(password); + } else { + throw new UnsupportedCallbackException(c); + } + } + } +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/client/KubernetesClientImplTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/client/KubernetesClientImplTest.java new file mode 100644 index 0000000000..f746fa6a20 --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/client/KubernetesClientImplTest.java @@ -0,0 +1,140 @@ +/* + * 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.artemis.spi.core.security.jaas.kubernetes.client; + +import static java.net.HttpURLConnection.HTTP_CREATED; +import static java.net.HttpURLConnection.HTTP_INTERNAL_ERROR; +import static org.apache.activemq.artemis.spi.core.security.jaas.KubernetesLoginModuleTest.AUTH_JSON; +import static org.apache.activemq.artemis.spi.core.security.jaas.KubernetesLoginModuleTest.UNAUTH_JSON; +import static org.apache.activemq.artemis.spi.core.security.jaas.KubernetesLoginModuleTest.USERNAME; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockserver.model.HttpRequest.request; +import static org.mockserver.model.HttpResponse.response; +import static org.mockserver.model.JsonBody.json; + +import java.net.URL; + +import org.apache.activemq.artemis.spi.core.security.jaas.kubernetes.model.TokenReview; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.mockserver.configuration.ConfigurationProperties; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.matchers.MatchType; +import org.mockserver.socket.PortFactory; +import org.mockserver.verify.VerificationTimes; + +public class KubernetesClientImplTest { + + private static final String API_PATH = "/apis/authentication.k8s.io/v1/tokenreviews"; + private static ClientAndServer mockServer; + private static final String host = "localhost"; + private static String port; + + private static final String BOB_REQUEST = "{\"apiVersion\": \"authentication.k8s.io/v1\"," + + "\"kind\": \"TokenReview\", \"spec\": {\"token\": \"bob_token\"}}"; + + private static final String KERMIT_REQUEST = "{\"apiVersion\": \"authentication.k8s.io/v1\"," + + "\"kind\": \"TokenReview\", \"spec\": {\"token\": \"kermit_token\"}}"; + + @BeforeClass + public static void startServer() { + ConfigurationProperties.dynamicallyCreateCertificateAuthorityCertificate(true); + ConfigurationProperties.directoryToSaveDynamicSSLCertificate("target/test-classes"); + ConfigurationProperties.proactivelyInitialiseTLS(true); + + mockServer = ClientAndServer.startClientAndServer(PortFactory.findFreePort()); + port = Integer.toString(mockServer.getPort()); + + assertNotNull(mockServer); + assertTrue(mockServer.isRunning()); + System.setProperty("KUBERNETES_SERVICE_HOST", host); + System.setProperty("KUBERNETES_SERVICE_PORT", port); + System.setProperty("KUBERNETES_TOKEN_PATH", + KubernetesClientImplTest.class.getClassLoader().getResource("client_token").getPath()); + URL caPath = KubernetesClientImplTest.class.getClassLoader() + .getResource("CertificateAuthorityCertificate.pem"); + if (caPath != null) { + System.setProperty("KUBERNETES_CA_PATH", caPath.getPath()); + } + + mockServer.when( + request() + .withMethod("POST") + .withPath(API_PATH) + .withBody(json(BOB_REQUEST, MatchType.STRICT))) + .respond( + response() + .withStatusCode(HTTP_CREATED) + .withBody(UNAUTH_JSON)); + + mockServer.when( + request() + .withMethod("POST") + .withPath(API_PATH) + .withBody(json(KERMIT_REQUEST, MatchType.STRICT))) + .respond( + response() + .withStatusCode(HTTP_CREATED) + .withBody(AUTH_JSON)); + + mockServer.when( + request() + .withMethod("POST") + .withPath(API_PATH)) + .respond( + response() + .withStatusCode(HTTP_INTERNAL_ERROR)); + + } + + @AfterClass + public static void stopServer() { + mockServer.stop(); + } + + @Test + public void testGetTokenReview() { + + KubernetesClient client = new KubernetesClientImpl(); + + TokenReview tr = client.getTokenReview("bob_token"); + assertNotNull(tr); + assertFalse(tr.isAuthenticated()); + assertNull(tr.getUser()); + assertNull(tr.getUsername()); + + tr = client.getTokenReview("kermit_token"); + assertNotNull(tr); + assertNotNull(tr.getUser()); + assertThat(tr.getUsername(), is(USERNAME)); + assertThat(tr.getUser().getUsername(), is(USERNAME)); + + tr = client.getTokenReview("other"); + assertNotNull(tr); + assertFalse(tr.isAuthenticated()); + + mockServer.verify(request().withPath(API_PATH), VerificationTimes.exactly(3)); + + } + +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/model/ServiceAccountPrincipalTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/model/ServiceAccountPrincipalTest.java new file mode 100644 index 0000000000..5f48c3282b --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/model/ServiceAccountPrincipalTest.java @@ -0,0 +1,50 @@ +/* + * 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.artemis.spi.core.security.jaas.kubernetes.model; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertNull; + +import org.apache.activemq.artemis.spi.core.security.jaas.ServiceAccountPrincipal; +import org.junit.Test; + +public class ServiceAccountPrincipalTest { + + @Test + public void testFullName() { + String name = "system:serviceaccounts:some-ns:some-sa"; + + ServiceAccountPrincipal principal = new ServiceAccountPrincipal(name); + + assertThat(principal.getNamespace(), is("some-ns")); + assertThat(principal.getSaName(), is("some-sa")); + assertThat(principal.getName(), is(name)); + } + + @Test + public void testSimpleName() { + String name = "foo"; + + ServiceAccountPrincipal principal = new ServiceAccountPrincipal(name); + + assertThat(principal.getName(), is("foo")); + assertNull(principal.getSaName()); + assertNull(principal.getNamespace()); + } + +} diff --git a/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/model/TokenReviewTest.java b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/model/TokenReviewTest.java new file mode 100644 index 0000000000..e3086bc38a --- /dev/null +++ b/artemis-server/src/test/java/org/apache/activemq/artemis/spi/core/security/jaas/kubernetes/model/TokenReviewTest.java @@ -0,0 +1,102 @@ +/* + * 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.artemis.spi.core.security.jaas.kubernetes.model; + +import static org.apache.activemq.artemis.spi.core.security.jaas.KubernetesLoginModuleTest.USERNAME; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.is; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.hamcrest.Matchers; +import org.junit.Test; + +public class TokenReviewTest { + + @Test + public void testEmpty() { + String json = "{}"; + TokenReview tr = TokenReview.fromJsonString(json); + + assertFalse(tr.isAuthenticated()); + assertNull(tr.getUser()); + assertNull(tr.getUsername()); + } + + @Test + public void testSimple() { + String json = "{\"status\": {\"authenticated\": true, \"user\": {\"username\": \"" + USERNAME + "\"}}}"; + + TokenReview tr = TokenReview.fromJsonString(json); + + assertNotNull(tr); + assertTrue(tr.isAuthenticated()); + assertThat(tr.getUsername(), is(USERNAME)); + assertNotNull(tr.getUser()); + assertThat(tr.getUser().getUsername(), is(USERNAME)); + assertThat(tr.getAudiences(), Matchers.empty()); + assertNull(tr.getUser().getExtra()); + } + + @Test + public void testCompleteObject() { + String json = "{\"status\": {" + + "\"authenticated\": true, " + + "\"user\": {" + + " \"username\": \"" + USERNAME + "\"," + + " \"uid\": \"kermit-uid\"," + + " \"groups\": [" + + " \"group-1\"," + + " \"group-2\"" + + " ]," + + " \"extra\": {" + + " \"authentication.kubernetes.io/pod-name\": [" + + " \"pod-1\"," + + " \"pod-2\"" + + " ]," + + " \"authentication.kubernetes.io/pod-uid\": [" + + " \"pod-uid-1\"," + + " \"pod-uid-2\"" + + " ]" + + " }" + + "}," + + "\"audiences\": [" + + " \"audience-1\"," + + " \"audience-2\"" + + "]}}"; + + TokenReview tr = TokenReview.fromJsonString(json); + + assertNotNull(tr); + assertTrue(tr.isAuthenticated()); + assertThat(tr.getUsername(), is(USERNAME)); + assertNotNull(tr.getUser()); + assertThat(tr.getUser().getUsername(), is(USERNAME)); + assertThat(tr.getAudiences(), containsInAnyOrder("audience-1", "audience-2")); + assertThat(tr.getUser().getGroups(), containsInAnyOrder("group-1", "group-2")); + assertThat(tr.getUser().getUid(), is("kermit-uid")); + + assertNotNull(tr.getUser().getExtra()); + assertThat(tr.getUser().getExtra().getPodNames(), containsInAnyOrder("pod-1", "pod-2")); + assertThat(tr.getUser().getExtra().getPodUids(), containsInAnyOrder("pod-uid-1", "pod-uid-2")); + + } + +} diff --git a/artemis-server/src/test/resources/client_token b/artemis-server/src/test/resources/client_token new file mode 100644 index 0000000000..a2909ae96c --- /dev/null +++ b/artemis-server/src/test/resources/client_token @@ -0,0 +1,17 @@ +## --------------------------------------------------------------------------- +## 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. +## --------------------------------------------------------------------------- +test_token \ No newline at end of file diff --git a/artemis-server/src/test/resources/k8s-roles.properties b/artemis-server/src/test/resources/k8s-roles.properties new file mode 100644 index 0000000000..fe7330316f --- /dev/null +++ b/artemis-server/src/test/resources/k8s-roles.properties @@ -0,0 +1,20 @@ +## --------------------------------------------------------------------------- +## 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. +## --------------------------------------------------------------------------- + +admin=system:serviceaccounts:some-ns:kermit +user=system:serviceaccounts:some-ns:gonzo,serviceaccounts:some-ns:joe +muppet=system:serviceaccounts:some-ns:kermit,system:serviceaccounts:some-ns:gonzo \ No newline at end of file diff --git a/docs/user-manual/en/security.md b/docs/user-manual/en/security.md index 7d49adbf0c..2cdb4692e5 100644 --- a/docs/user-manual/en/security.md +++ b/docs/user-manual/en/security.md @@ -1056,6 +1056,37 @@ org.apache.activemq.artemis.spi.core.security.jaas.Krb5LoginModule required The simplest way to make the login configuration available to JAAS is to add the directory containing the file, `login.config`, to your CLASSPATH. +#### KubernetesLoginModule + +The Kubernetes login module enables you to perform authentication and authorization +by validating the `Bearer` token against the Kubernetes API. The authentication is done +by submitting a `TokenReview` request that the Kubernetes cluster validates. The response will +tell whether the user is authenticated and the associated username. It is implemented by `org.apache.activemq.artemis.spi.core.security.jaas.KubernetesLoginModule`. + +- `org.apache.activemq.jaas.kubernetes.role` - the path to the file which + contains user and role mapping + +- `reload` - boolean flag; whether or not to reload the properties files when a + modification occurs; default is `false` + +- `debug` - boolean flag; if `true`, enable debugging; this is used only for + testing or debugging; normally, it should be set to `false`, or omitted; + default is `false` + +The login module must be allowed to query such Rest API. For that, it will use the available +token under `/var/run/secrets/kubernetes.io/serviceaccount/token`. Besides, in order to trust the +connection the client will use the `ca.crt` file existing in the same folder. These two files will +be mounted in the container. The service account running the KubernetesLoginModule must +be allowed to `create::TokenReview`. The `system:auth-delegator` role is typically use for +that purpose. + +The `k8s-roles.properties` file consists of a list of properties of the form, `Role=UserList`, where `UserList` is a comma-separated list of users. For example, to define the roles admins, users, and guests, you could create a file like the following: + +```properties +admins=system:serviceaccounts:example-ns:admin-sa +users=system:serviceaccounts:other-ns:test-sa +``` + ### SCRAM-SHA SASL Mechanism SCRAM (Salted Challenge Response Authentication Mechanism) is an authentication mechanism that can establish mutual diff --git a/pom.xml b/pom.xml index 51e76bfbf8..5f6269bcdf 100644 --- a/pom.xml +++ b/pom.xml @@ -169,6 +169,7 @@ 4.0.5 4.3.3 3.3.1 + 5.13.2 6.1.0 5.3.20 @@ -232,7 +233,7 @@ 2.0.0.AM25 2.0.0-M1 - 1.69 + 1.70 linux-x86_64 osx-x86_64