[ARTEMIS-3168] Implement Kubernetes JaaS LoginModule

Signed-off-by: ruromero <rromerom@redhat.com>
This commit is contained in:
ruromero 2022-11-21 15:23:22 +01:00 committed by Gary Tully
parent eb0aa118b4
commit 3e50014e0d
17 changed files with 1110 additions and 3 deletions

View File

@ -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,
*
</Import-Package>
<_exportcontents>org.apache.activemq.artemis.*;-noimport:=true</_exportcontents>

View File

@ -14,7 +14,8 @@
See the License for the specific language governing permissions and
limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
@ -263,6 +264,12 @@
<artifactId>jakarta.json-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mock-server</groupId>
<artifactId>mockserver-netty</artifactId>
<version>${mockserver.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<profiles>

View File

@ -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<String, Set<String>> roles;
private final Set<Principal> 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<String, ?> sharedState,
Map<String, ?> 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<UserPrincipal> 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<String> 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();
}
}

View File

@ -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));
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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<String> 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;
}
}

View File

@ -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<String> audiences;
public boolean isAuthenticated() {
return authenticated;
}
public User getUser() {
return user;
}
public String getUsername() {
if (user == null) {
return null;
}
return user.getUsername();
}
public List<String> 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<String> 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<String> groups;
private Extra extra;
public Extra getExtra() {
return extra;
}
public List<String> 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<String> podNames;
private List<String> podUids;
public List<String> getPodNames() {
return podNames;
}
public List<String> 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;
}
}
}

View File

@ -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<RolePrincipal> 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<String, ?> getDefaultOptions() {
return Map.of(K8S_ROLE_FILE_PROP_NAME,
"k8s-roles.properties");
}
}

View File

@ -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);
}
}
}
}

View File

@ -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));
}
}

View File

@ -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());
}
}

View File

@ -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"));
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -169,6 +169,7 @@
<groovy.version>4.0.5</groovy.version>
<vertx.version>4.3.3</vertx.version>
<hadoop.minikdc.version>3.3.1</hadoop.minikdc.version>
<mockserver.version>5.13.2</mockserver.version>
<owasp.version>6.1.0</owasp.version>
<spring.version>5.3.20</spring.version>
@ -232,7 +233,7 @@
<directory-version>2.0.0.AM25</directory-version>
<directory-jdbm2-version>2.0.0-M1</directory-jdbm2-version>
<bcprov-jdk15on-version>1.69</bcprov-jdk15on-version>
<bcprov-jdk15on-version>1.70</bcprov-jdk15on-version>
<netty-transport-native-epoll-classifier>linux-x86_64</netty-transport-native-epoll-classifier>
<netty-transport-native-kqueue-classifier>osx-x86_64</netty-transport-native-kqueue-classifier>