createEndpointSpecificChannelHandlers()
+ throws ConfigurationException {
+ return Collections.emptyList();
+ }
+
+ /**
+ * Creates endpoint-specific Netty handlers that run before multipart upload handling.
+ *
+ * These handlers see raw HTTP messages and are intended for endpoint-local guards that must
+ * run before request bodies can be written to the upload directory.
+ */
+ protected Collection createEndpointSpecificPreFileUploadChannelHandlers()
+ throws ConfigurationException {
+ return Collections.emptyList();
+ }
+
/**
* Starts this REST server endpoint.
*
@@ -219,13 +239,24 @@ protected void initChannel(SocketChannel ch) throws ConfigurationException {
sslHandlerFactory));
}
+ ch.pipeline().addLast(new HttpServerCodec());
+
+ for (ChannelHandler channelHandler :
+ createEndpointSpecificPreFileUploadChannelHandlers()) {
+ ch.pipeline().addLast(channelHandler);
+ }
+
ch.pipeline()
- .addLast(new HttpServerCodec())
.addLast(new FileUploadHandler(uploadDir, multipartRoutes))
.addLast(
new FlinkHttpObjectAggregator(
maxContentLength, responseHeaders));
+ for (ChannelHandler channelHandler :
+ createEndpointSpecificChannelHandlers()) {
+ ch.pipeline().addLast(channelHandler);
+ }
+
for (InboundChannelHandlerFactory factory :
inboundChannelHandlerFactories) {
Optional channelHandler =
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/WebMonitorEndpoint.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/WebMonitorEndpoint.java
index c54631c8faf35..d89547a9c473c 100644
--- a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/WebMonitorEndpoint.java
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/WebMonitorEndpoint.java
@@ -166,6 +166,9 @@
import org.apache.flink.runtime.webmonitor.history.ArchivedJson;
import org.apache.flink.runtime.webmonitor.history.JsonArchivist;
import org.apache.flink.runtime.webmonitor.retriever.GatewayRetriever;
+import org.apache.flink.runtime.webmonitor.security.JobManagerAuthenticatedUserHandler;
+import org.apache.flink.runtime.webmonitor.security.JobManagerAuthenticatedUserHeaders;
+import org.apache.flink.runtime.webmonitor.security.JobManagerWebAuthenticationHandler;
import org.apache.flink.runtime.webmonitor.threadinfo.ThreadInfoRequestCoordinator;
import org.apache.flink.runtime.webmonitor.threadinfo.VertexThreadInfoTracker;
import org.apache.flink.runtime.webmonitor.threadinfo.VertexThreadInfoTrackerBuilder;
@@ -179,6 +182,7 @@
import org.apache.flink.shaded.guava33.com.google.common.cache.Cache;
import org.apache.flink.shaded.guava33.com.google.common.cache.CacheBuilder;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandler;
import org.apache.flink.shaded.netty4.io.netty.channel.ChannelInboundHandler;
import javax.annotation.Nullable;
@@ -228,6 +232,9 @@ public class WebMonitorEndpoint extends RestServerEndp
private final Collection archivingHandlers = new ArrayList<>(16);
+ private final Optional
+ webAuthenticationHandlerFactory;
+
@Nullable private ScheduledFuture> executionGraphCleanupTask;
public WebMonitorEndpoint(
@@ -264,6 +271,25 @@ public WebMonitorEndpoint(
this.leaderElection = Preconditions.checkNotNull(leaderElection);
this.fatalErrorHandler = Preconditions.checkNotNull(fatalErrorHandler);
+ this.webAuthenticationHandlerFactory =
+ JobManagerWebAuthenticationHandler.createFactory(
+ this.clusterConfiguration, responseHeaders);
+ }
+
+ @Override
+ protected Collection createEndpointSpecificChannelHandlers() {
+ return webAuthenticationHandlerFactory
+ .>map(
+ factory -> Collections.singletonList(factory.createHandler()))
+ .orElseGet(Collections::emptyList);
+ }
+
+ @Override
+ protected Collection createEndpointSpecificPreFileUploadChannelHandlers() {
+ return webAuthenticationHandlerFactory
+ .>map(
+ factory -> Collections.singletonList(factory.createPreFileUploadHandler()))
+ .orElseGet(Collections::emptyList);
}
private VertexThreadInfoTracker initializeThreadInfoTracker(ScheduledExecutorService executor) {
@@ -320,6 +346,9 @@ protected List> initiali
restConfiguration.isWebCancelEnabled(),
restConfiguration.isWebRescaleEnabled());
+ JobManagerAuthenticatedUserHandler authenticatedUserHandler =
+ new JobManagerAuthenticatedUserHandler();
+
JobIdsHandler jobIdsHandler =
new JobIdsHandler(
leaderRetriever,
@@ -750,6 +779,10 @@ protected List> initiali
jobManagerJobEnvironmentHandler.getMessageHeaders(),
jobManagerJobEnvironmentHandler));
handlers.add(Tuple2.of(dashboardConfigHandler.getMessageHeaders(), dashboardConfigHandler));
+ handlers.add(
+ Tuple2.of(
+ JobManagerAuthenticatedUserHeaders.getInstance(),
+ authenticatedUserHandler));
handlers.add(Tuple2.of(jobIdsHandler.getMessageHeaders(), jobIdsHandler));
handlers.add(Tuple2.of(jobStatusHandler.getMessageHeaders(), jobStatusHandler));
handlers.add(Tuple2.of(jobsOverviewHandler.getMessageHeaders(), jobsOverviewHandler));
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUser.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUser.java
new file mode 100644
index 0000000000000..6012bdc6bcdaa
--- /dev/null
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUser.java
@@ -0,0 +1,47 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.util.Preconditions;
+
+/** Authenticated JobManager web user for a single HTTP request. */
+public final class JobManagerAuthenticatedUser {
+
+ private final String userName;
+ private final String principal;
+ private final String type;
+
+ JobManagerAuthenticatedUser(String userName, String principal, String type) {
+ this.userName = Preconditions.checkNotNull(userName);
+ this.principal = Preconditions.checkNotNull(principal);
+ this.type = Preconditions.checkNotNull(type);
+ }
+
+ public String getUserName() {
+ return userName;
+ }
+
+ public String getPrincipal() {
+ return principal;
+ }
+
+ public String getType() {
+ return type;
+ }
+}
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUserHandler.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUserHandler.java
new file mode 100644
index 0000000000000..9a5afe97063a7
--- /dev/null
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUserHandler.java
@@ -0,0 +1,109 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.runtime.rest.handler.router.RoutedRequest;
+import org.apache.flink.runtime.rest.handler.util.HandlerUtils;
+import org.apache.flink.runtime.rest.messages.ResponseBody;
+
+import org.apache.flink.shaded.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import org.apache.flink.shaded.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandler;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandlerContext;
+import org.apache.flink.shaded.netty4.io.netty.channel.SimpleChannelInboundHandler;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaderNames;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponseStatus;
+
+import javax.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+/** Handler for exposing the current JobManager web user to the dashboard. */
+@ChannelHandler.Sharable
+public final class JobManagerAuthenticatedUserHandler
+ extends SimpleChannelInboundHandler> {
+
+ private static final Map NO_STORE_HEADERS = createNoStoreHeaders();
+
+ @Override
+ protected void channelRead0(ChannelHandlerContext ctx, RoutedRequest routedRequest) {
+ JobManagerAuthenticatedUserResponseBody response =
+ JobManagerWebAuthenticationHandler.getAuthenticatedUser(ctx)
+ .map(JobManagerAuthenticatedUserResponseBody::authenticated)
+ .orElseGet(JobManagerAuthenticatedUserResponseBody::unauthenticated);
+
+ HandlerUtils.sendResponse(
+ ctx, routedRequest.getRequest(), response, HttpResponseStatus.OK, NO_STORE_HEADERS);
+ }
+
+ private static Map createNoStoreHeaders() {
+ Map headers = new HashMap<>();
+ headers.put(HttpHeaderNames.CACHE_CONTROL.toString(), "no-store");
+ headers.put(HttpHeaderNames.PRAGMA.toString(), "no-cache");
+ return Collections.unmodifiableMap(headers);
+ }
+
+ /** Response body for the current authenticated JobManager web user. */
+ public static final class JobManagerAuthenticatedUserResponseBody implements ResponseBody {
+
+ public static final String FIELD_NAME_AUTHENTICATED = "authenticated";
+ public static final String FIELD_NAME_USER = "user";
+ public static final String FIELD_NAME_PRINCIPAL = "principal";
+ public static final String FIELD_NAME_TYPE = "type";
+
+ @JsonProperty(FIELD_NAME_AUTHENTICATED)
+ private final boolean authenticated;
+
+ @JsonProperty(FIELD_NAME_USER)
+ @Nullable
+ private final String user;
+
+ @JsonProperty(FIELD_NAME_PRINCIPAL)
+ @Nullable
+ private final String principal;
+
+ @JsonProperty(FIELD_NAME_TYPE)
+ @Nullable
+ private final String type;
+
+ @JsonCreator
+ public JobManagerAuthenticatedUserResponseBody(
+ @JsonProperty(FIELD_NAME_AUTHENTICATED) boolean authenticated,
+ @Nullable @JsonProperty(FIELD_NAME_USER) String user,
+ @Nullable @JsonProperty(FIELD_NAME_PRINCIPAL) String principal,
+ @Nullable @JsonProperty(FIELD_NAME_TYPE) String type) {
+ this.authenticated = authenticated;
+ this.user = user;
+ this.principal = principal;
+ this.type = type;
+ }
+
+ static JobManagerAuthenticatedUserResponseBody authenticated(
+ JobManagerAuthenticatedUser user) {
+ return new JobManagerAuthenticatedUserResponseBody(
+ true, user.getUserName(), user.getPrincipal(), user.getType());
+ }
+
+ static JobManagerAuthenticatedUserResponseBody unauthenticated() {
+ return new JobManagerAuthenticatedUserResponseBody(false, null, null, null);
+ }
+ }
+}
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUserHeaders.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUserHeaders.java
new file mode 100644
index 0000000000000..b507d6010341f
--- /dev/null
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUserHeaders.java
@@ -0,0 +1,81 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.runtime.rest.HttpMethodWrapper;
+import org.apache.flink.runtime.rest.messages.EmptyMessageParameters;
+import org.apache.flink.runtime.rest.messages.EmptyRequestBody;
+import org.apache.flink.runtime.rest.messages.RuntimeMessageHeaders;
+
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponseStatus;
+
+/** Message headers for exposing the current JobManager web user to the dashboard. */
+public final class JobManagerAuthenticatedUserHeaders
+ implements RuntimeMessageHeaders<
+ EmptyRequestBody,
+ JobManagerAuthenticatedUserHandler.JobManagerAuthenticatedUserResponseBody,
+ EmptyMessageParameters> {
+
+ private static final JobManagerAuthenticatedUserHeaders INSTANCE =
+ new JobManagerAuthenticatedUserHeaders();
+
+ public static final String URL = "/auth/user";
+
+ private JobManagerAuthenticatedUserHeaders() {}
+
+ @Override
+ public Class getRequestClass() {
+ return EmptyRequestBody.class;
+ }
+
+ @Override
+ public HttpMethodWrapper getHttpMethod() {
+ return HttpMethodWrapper.GET;
+ }
+
+ @Override
+ public String getTargetRestEndpointURL() {
+ return URL;
+ }
+
+ @Override
+ public Class
+ getResponseClass() {
+ return JobManagerAuthenticatedUserHandler.JobManagerAuthenticatedUserResponseBody.class;
+ }
+
+ @Override
+ public HttpResponseStatus getResponseStatusCode() {
+ return HttpResponseStatus.OK;
+ }
+
+ @Override
+ public EmptyMessageParameters getUnresolvedMessageParameters() {
+ return EmptyMessageParameters.getInstance();
+ }
+
+ @Override
+ public String getDescription() {
+ return "Returns the current authenticated JobManager web user.";
+ }
+
+ public static JobManagerAuthenticatedUserHeaders getInstance() {
+ return INSTANCE;
+ }
+}
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticationTokenSigner.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticationTokenSigner.java
new file mode 100644
index 0000000000000..6077baca74096
--- /dev/null
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticationTokenSigner.java
@@ -0,0 +1,126 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Optional;
+
+import static org.apache.flink.util.Preconditions.checkArgument;
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/** Signs and verifies Hadoop-compatible JobManager web authentication tokens. */
+final class JobManagerAuthenticationTokenSigner {
+
+ static final String TOKEN_TYPE_KERBEROS = "kerberos";
+
+ private static final String SIGNATURE_SEPARATOR = "&s=";
+ private static final String SIGNING_ALGORITHM = "HmacSHA256";
+
+ private final byte[] secret;
+ private final Duration tokenValidity;
+ private final Clock clock;
+
+ JobManagerAuthenticationTokenSigner(byte[] secret, Duration tokenValidity, Clock clock) {
+ this.secret = checkNotNull(secret, "secret must not be null").clone();
+ checkArgument(this.secret.length > 0, "secret must not be empty");
+ this.tokenValidity = checkNotNull(tokenValidity, "tokenValidity must not be null");
+ this.clock = checkNotNull(clock, "clock must not be null");
+ }
+
+ String signToken(String userName, String principal) {
+ AuthenticationToken token =
+ new AuthenticationToken(userName, principal, TOKEN_TYPE_KERBEROS);
+ token.setExpires(clock.millis() + tokenValidity.toMillis());
+ return sign(token.toString());
+ }
+
+ Optional verifyToken(String signedToken) {
+ if (signedToken == null || signedToken.isEmpty()) {
+ return Optional.empty();
+ }
+ try {
+ AuthenticationToken token =
+ AuthenticationToken.parse(verifyAndExtract(stripQuotes(signedToken)));
+ if (!TOKEN_TYPE_KERBEROS.equals(token.getType())) {
+ return Optional.empty();
+ }
+ if (token.getExpires() != -1 && clock.millis() > token.getExpires()) {
+ return Optional.empty();
+ }
+ return Optional.of(token);
+ } catch (AuthenticationException | IllegalArgumentException e) {
+ return Optional.empty();
+ }
+ }
+
+ private String sign(String rawToken) {
+ return rawToken + SIGNATURE_SEPARATOR + computeSignature(rawToken);
+ }
+
+ private String verifyAndExtract(String signedToken) {
+ int index = signedToken.lastIndexOf(SIGNATURE_SEPARATOR);
+ if (index < 0) {
+ throw new IllegalArgumentException("Authentication token is not signed.");
+ }
+
+ String rawToken = signedToken.substring(0, index);
+ String expectedSignature = computeSignature(rawToken);
+ String actualSignature = signedToken.substring(index + SIGNATURE_SEPARATOR.length());
+ if (!MessageDigest.isEqual(
+ expectedSignature.getBytes(StandardCharsets.UTF_8),
+ actualSignature.getBytes(StandardCharsets.UTF_8))) {
+ throw new IllegalArgumentException("Authentication token signature is invalid.");
+ }
+ return rawToken;
+ }
+
+ private String computeSignature(String value) {
+ try {
+ Mac mac = Mac.getInstance(SIGNING_ALGORITHM);
+ mac.init(new SecretKeySpec(secret, SIGNING_ALGORITHM));
+ return Base64.getEncoder()
+ .encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new IllegalStateException(
+ "Could not sign JobManager web authentication token.", e);
+ }
+ }
+
+ private static String stripQuotes(String value) {
+ if (value != null
+ && value.length() >= 2
+ && value.startsWith("\"")
+ && value.endsWith("\"")) {
+ return value.substring(1, value.length() - 1);
+ }
+ return value;
+ }
+}
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerSpnegoAuthenticationResult.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerSpnegoAuthenticationResult.java
new file mode 100644
index 0000000000000..e56f2a06a0d91
--- /dev/null
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerSpnegoAuthenticationResult.java
@@ -0,0 +1,62 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+
+import javax.annotation.Nullable;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/** Result of one JobManager web SPNEGO authentication step. */
+final class JobManagerSpnegoAuthenticationResult {
+
+ private final AuthenticationToken authenticationToken;
+ private final String authenticateHeader;
+
+ private JobManagerSpnegoAuthenticationResult(
+ @Nullable AuthenticationToken authenticationToken,
+ @Nullable String authenticateHeader) {
+ this.authenticationToken = authenticationToken;
+ this.authenticateHeader = authenticateHeader;
+ }
+
+ static JobManagerSpnegoAuthenticationResult challenge(String authenticateHeader) {
+ return new JobManagerSpnegoAuthenticationResult(null, checkNotNull(authenticateHeader));
+ }
+
+ static JobManagerSpnegoAuthenticationResult authenticated(
+ AuthenticationToken authenticationToken, @Nullable String authenticateHeader) {
+ return new JobManagerSpnegoAuthenticationResult(
+ checkNotNull(authenticationToken), authenticateHeader);
+ }
+
+ boolean isAuthenticated() {
+ return authenticationToken != null;
+ }
+
+ AuthenticationToken authenticationToken() {
+ return checkNotNull(authenticationToken);
+ }
+
+ @Nullable
+ String authenticateHeader() {
+ return authenticateHeader;
+ }
+}
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerSpnegoAuthenticator.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerSpnegoAuthenticator.java
new file mode 100644
index 0000000000000..be58c232fd6f9
--- /dev/null
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerSpnegoAuthenticator.java
@@ -0,0 +1,283 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.util.ConfigurationException;
+
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+import org.apache.hadoop.security.authentication.util.KerberosName;
+import org.apache.hadoop.security.authentication.util.KerberosUtil;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSCredential;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.Oid;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+import javax.security.auth.Subject;
+import javax.security.auth.kerberos.KerberosKey;
+import javax.security.auth.kerberos.KerberosPrincipal;
+import javax.security.auth.kerberos.KeyTab;
+
+import java.io.File;
+import java.io.IOException;
+import java.security.Principal;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.Base64;
+import java.util.regex.Pattern;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/** Kerberos/SPNEGO acceptor for the JobManager web endpoint. */
+final class JobManagerSpnegoAuthenticator implements SpnegoAuthenticator {
+
+ private static final Logger LOG = LoggerFactory.getLogger(JobManagerSpnegoAuthenticator.class);
+
+ static final String AUTHENTICATION_SCHEME_NEGOTIATE = "Negotiate";
+ static final String AUTHENTICATE_HEADER_NEGOTIATE = "Negotiate";
+
+ private static final Pattern HTTP_PRINCIPAL_PATTERN = Pattern.compile("HTTP/.*");
+
+ private final Subject serverSubject;
+ private final GSSManager gssManager;
+
+ private JobManagerSpnegoAuthenticator(Subject serverSubject, GSSManager gssManager) {
+ this.serverSubject = checkNotNull(serverSubject);
+ this.gssManager = checkNotNull(gssManager);
+ }
+
+ static JobManagerSpnegoAuthenticator create(JobManagerWebAuthenticationConfig config)
+ throws ConfigurationException {
+ File keytabFile = config.getKerberosKeytab().toFile();
+ if (!keytabFile.isFile()) {
+ throw new ConfigurationException(
+ "JobManager web authentication Kerberos keytab does not exist or is not a file: "
+ + config.getKerberosKeytab());
+ }
+
+ Subject serverSubject = new Subject();
+ KeyTab keytab = KeyTab.getInstance(keytabFile);
+ serverSubject.getPrivateCredentials().add(keytab);
+
+ if ("*".equals(config.getKerberosPrincipal())) {
+ addWildcardHttpPrincipals(serverSubject, config.getKerberosKeytab().toString());
+ } else {
+ addPrincipal(
+ serverSubject,
+ keytab,
+ config.getKerberosPrincipal(),
+ config.getKerberosKeytab().toString());
+ }
+
+ configureKerberosNameRules(config);
+
+ GSSManager gssManager;
+ try {
+ gssManager =
+ Subject.doAs(
+ serverSubject,
+ (PrivilegedExceptionAction) GSSManager::getInstance);
+ } catch (PrivilegedActionException e) {
+ throw new ConfigurationException(
+ "Could not initialize JobManager web SPNEGO acceptor.", e.getException());
+ }
+
+ return new JobManagerSpnegoAuthenticator(serverSubject, gssManager);
+ }
+
+ @Override
+ public JobManagerSpnegoAuthenticationResult authenticate(String authorizationHeader)
+ throws AuthenticationException {
+ if (!hasNegotiateScheme(authorizationHeader)) {
+ return JobManagerSpnegoAuthenticationResult.challenge(AUTHENTICATE_HEADER_NEGOTIATE);
+ }
+
+ String encodedClientToken =
+ authorizationHeader.substring(AUTHENTICATION_SCHEME_NEGOTIATE.length()).trim();
+ if (encodedClientToken.isEmpty()) {
+ throw new AuthenticationException("Missing SPNEGO token.");
+ }
+
+ byte[] clientToken;
+ try {
+ clientToken = Base64.getDecoder().decode(encodedClientToken);
+ } catch (IllegalArgumentException e) {
+ throw new AuthenticationException("Invalid SPNEGO token encoding.", e);
+ }
+
+ try {
+ String serverPrincipal = KerberosUtil.getTokenServerName(clientToken);
+ if (!serverPrincipal.startsWith("HTTP/")) {
+ throw new AuthenticationException(
+ "Invalid SPNEGO server principal in client token.");
+ }
+ return Subject.doAs(
+ serverSubject,
+ (PrivilegedExceptionAction)
+ () -> runWithPrincipal(serverPrincipal, clientToken));
+ } catch (PrivilegedActionException e) {
+ Throwable cause = e.getException();
+ if (cause instanceof AuthenticationException) {
+ throw (AuthenticationException) cause;
+ }
+ throw new AuthenticationException(cause);
+ } catch (AuthenticationException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new AuthenticationException(e);
+ }
+ }
+
+ private JobManagerSpnegoAuthenticationResult runWithPrincipal(
+ String serverPrincipal, byte[] clientToken) throws GSSException, IOException {
+ GSSContext gssContext = null;
+ GSSCredential gssCredential = null;
+ try {
+ LOG.trace(
+ "Starting JobManager web SPNEGO step for server principal {}.",
+ serverPrincipal);
+ gssCredential =
+ gssManager.createCredential(
+ gssManager.createName(
+ serverPrincipal, KerberosUtil.NT_GSS_KRB5_PRINCIPAL_OID),
+ GSSCredential.INDEFINITE_LIFETIME,
+ new Oid[] {
+ KerberosUtil.GSS_SPNEGO_MECH_OID, KerberosUtil.GSS_KRB5_MECH_OID
+ },
+ GSSCredential.ACCEPT_ONLY);
+ gssContext = gssManager.createContext(gssCredential);
+ byte[] serverToken = gssContext.acceptSecContext(clientToken, 0, clientToken.length);
+ String authenticateHeader = createAuthenticateHeader(serverToken);
+
+ if (!gssContext.isEstablished()) {
+ LOG.trace("JobManager web SPNEGO step is not complete yet.");
+ return JobManagerSpnegoAuthenticationResult.challenge(
+ authenticateHeader == null
+ ? AUTHENTICATE_HEADER_NEGOTIATE
+ : authenticateHeader);
+ }
+
+ String clientPrincipal = gssContext.getSrcName().toString();
+ String userName = new KerberosName(clientPrincipal).getShortName();
+ AuthenticationToken token =
+ new AuthenticationToken(
+ userName,
+ clientPrincipal,
+ JobManagerAuthenticationTokenSigner.TOKEN_TYPE_KERBEROS);
+ LOG.trace("JobManager web SPNEGO completed for client principal {}.", clientPrincipal);
+ return JobManagerSpnegoAuthenticationResult.authenticated(token, authenticateHeader);
+ } finally {
+ if (gssContext != null) {
+ gssContext.dispose();
+ }
+ if (gssCredential != null) {
+ gssCredential.dispose();
+ }
+ }
+ }
+
+ @Nullable
+ private static String createAuthenticateHeader(byte[] serverToken) {
+ if (serverToken == null || serverToken.length == 0) {
+ return null;
+ }
+ return AUTHENTICATE_HEADER_NEGOTIATE
+ + " "
+ + Base64.getEncoder().encodeToString(serverToken);
+ }
+
+ private static boolean hasNegotiateScheme(String authorizationHeader) {
+ return authorizationHeader != null
+ && authorizationHeader.length() >= AUTHENTICATION_SCHEME_NEGOTIATE.length()
+ && authorizationHeader.regionMatches(
+ true,
+ 0,
+ AUTHENTICATION_SCHEME_NEGOTIATE,
+ 0,
+ AUTHENTICATION_SCHEME_NEGOTIATE.length())
+ && (authorizationHeader.length() == AUTHENTICATION_SCHEME_NEGOTIATE.length()
+ || Character.isWhitespace(
+ authorizationHeader.charAt(
+ AUTHENTICATION_SCHEME_NEGOTIATE.length())));
+ }
+
+ private static void addWildcardHttpPrincipals(Subject serverSubject, String keytab)
+ throws ConfigurationException {
+ String[] principals;
+ try {
+ principals = KerberosUtil.getPrincipalNames(keytab, HTTP_PRINCIPAL_PATTERN);
+ } catch (IOException e) {
+ throw new ConfigurationException(
+ "Could not read JobManager web authentication Kerberos keytab.", e);
+ }
+ if (principals.length == 0) {
+ throw new ConfigurationException(
+ "JobManager web authentication Kerberos keytab does not contain any HTTP principals.");
+ }
+ for (String principal : principals) {
+ addPrincipal(serverSubject, null, principal, keytab);
+ }
+ }
+
+ private static void addPrincipal(
+ Subject serverSubject, KeyTab keytab, String principal, String keytabPath)
+ throws ConfigurationException {
+ try {
+ Principal kerberosPrincipal = new KerberosPrincipal(principal);
+ if (keytab != null) {
+ KerberosKey[] keys = keytab.getKeys((KerberosPrincipal) kerberosPrincipal);
+ if (keys.length == 0) {
+ throw new ConfigurationException(
+ String.format(
+ "JobManager web authentication Kerberos keytab '%s' does not contain principal '%s'.",
+ keytabPath, principal));
+ }
+ for (KerberosKey key : keys) {
+ key.destroy();
+ }
+ }
+ serverSubject.getPrincipals().add(kerberosPrincipal);
+ LOG.info(
+ "Using JobManager web SPNEGO keytab {} for principal {}.",
+ keytabPath,
+ principal);
+ } catch (IllegalArgumentException e) {
+ throw new ConfigurationException(
+ "Invalid JobManager web authentication Kerberos principal: " + principal, e);
+ } catch (javax.security.auth.DestroyFailedException e) {
+ throw new ConfigurationException(
+ "Could not validate JobManager web authentication Kerberos keytab.", e);
+ }
+ }
+
+ private static void configureKerberosNameRules(JobManagerWebAuthenticationConfig config)
+ throws ConfigurationException {
+ try {
+ KerberosName.setRules(config.getKerberosNameRules());
+ KerberosName.setRuleMechanism(KerberosName.DEFAULT_MECHANISM);
+ } catch (IllegalArgumentException e) {
+ throw new ConfigurationException(
+ "Invalid JobManager web SPNEGO Kerberos name rules.", e);
+ }
+ }
+}
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationConfig.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationConfig.java
new file mode 100644
index 0000000000000..a9350406403ee
--- /dev/null
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationConfig.java
@@ -0,0 +1,295 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.RestOptions;
+import org.apache.flink.configuration.SecurityOptions;
+import org.apache.flink.configuration.WebOptions.WebAuthenticationType;
+import org.apache.flink.util.ConfigurationException;
+import org.apache.flink.util.StringUtils;
+
+import org.apache.hadoop.security.authentication.util.KerberosUtil;
+
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Optional;
+
+import static org.apache.flink.configuration.WebOptions.AUTHENTICATION_COOKIE_PATH;
+import static org.apache.flink.configuration.WebOptions.AUTHENTICATION_KERBEROS_KEYTAB;
+import static org.apache.flink.configuration.WebOptions.AUTHENTICATION_KERBEROS_NAME_RULES;
+import static org.apache.flink.configuration.WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL;
+import static org.apache.flink.configuration.WebOptions.AUTHENTICATION_SIGNATURE_SECRET;
+import static org.apache.flink.configuration.WebOptions.AUTHENTICATION_SIGNATURE_SECRET_FILE;
+import static org.apache.flink.configuration.WebOptions.AUTHENTICATION_TOKEN_VALIDITY;
+import static org.apache.flink.configuration.WebOptions.AUTHENTICATION_TYPE;
+
+/** Parsed and validated JobManager web authentication configuration. */
+final class JobManagerWebAuthenticationConfig {
+
+ private static final int RANDOM_SECRET_BYTES = 32;
+
+ private final String kerberosPrincipal;
+ private final Path kerberosKeytab;
+ private final String kerberosNameRules;
+ private final Duration tokenValidity;
+ private final String cookiePath;
+ private final byte[] signatureSecret;
+ private final boolean secureCookie;
+
+ private JobManagerWebAuthenticationConfig(
+ String kerberosPrincipal,
+ Path kerberosKeytab,
+ String kerberosNameRules,
+ Duration tokenValidity,
+ String cookiePath,
+ byte[] signatureSecret,
+ boolean secureCookie) {
+ this.kerberosPrincipal = kerberosPrincipal;
+ this.kerberosKeytab = kerberosKeytab;
+ this.kerberosNameRules = kerberosNameRules;
+ this.tokenValidity = tokenValidity;
+ this.cookiePath = cookiePath;
+ this.signatureSecret = signatureSecret.clone();
+ this.secureCookie = secureCookie;
+ }
+
+ static Optional fromConfiguration(
+ Configuration configuration) throws ConfigurationException {
+ WebAuthenticationType type = getAuthenticationType(configuration);
+ if (type == WebAuthenticationType.NONE) {
+ return Optional.empty();
+ }
+ if (type != WebAuthenticationType.KERBEROS) {
+ throw new ConfigurationException(
+ "Unsupported " + AUTHENTICATION_TYPE.key() + " value: " + type);
+ }
+
+ return Optional.of(
+ new JobManagerWebAuthenticationConfig(
+ resolvePrincipal(
+ required(configuration, AUTHENTICATION_KERBEROS_PRINCIPAL),
+ configuration),
+ resolveKeytab(required(configuration, AUTHENTICATION_KERBEROS_KEYTAB)),
+ configuration.get(AUTHENTICATION_KERBEROS_NAME_RULES),
+ validateTokenValidity(configuration.get(AUTHENTICATION_TOKEN_VALIDITY)),
+ validateCookiePath(configuration.get(AUTHENTICATION_COOKIE_PATH)),
+ resolveSignatureSecret(configuration),
+ SecurityOptions.isRestSSLEnabled(configuration)));
+ }
+
+ String getKerberosPrincipal() {
+ return kerberosPrincipal;
+ }
+
+ Path getKerberosKeytab() {
+ return kerberosKeytab;
+ }
+
+ String getKerberosNameRules() {
+ return kerberosNameRules;
+ }
+
+ Duration getTokenValidity() {
+ return tokenValidity;
+ }
+
+ String getCookiePath() {
+ return cookiePath;
+ }
+
+ byte[] getSignatureSecret() {
+ return signatureSecret.clone();
+ }
+
+ boolean isSecureCookie() {
+ return secureCookie;
+ }
+
+ private static WebAuthenticationType getAuthenticationType(Configuration configuration)
+ throws ConfigurationException {
+ try {
+ return configuration.get(AUTHENTICATION_TYPE);
+ } catch (IllegalArgumentException e) {
+ throw new ConfigurationException(
+ "Unsupported " + AUTHENTICATION_TYPE.key() + " value.", e);
+ }
+ }
+
+ private static String resolvePrincipal(String principal, Configuration configuration)
+ throws ConfigurationException {
+ if ("*".equals(principal)) {
+ return principal;
+ }
+ String resolvedPrincipal = principal.replace("_HOST", resolveHostName(configuration));
+ if (!resolvedPrincipal.startsWith("HTTP/")) {
+ throw new ConfigurationException(
+ AUTHENTICATION_KERBEROS_PRINCIPAL.key() + " must start with HTTP/ or be '*'.");
+ }
+ return resolvedPrincipal;
+ }
+
+ private static String resolveHostName(Configuration configuration)
+ throws ConfigurationException {
+ Optional configuredHost =
+ firstUsableHost(
+ configuration.getOptional(RestOptions.ADDRESS),
+ configuration.getOptional(RestOptions.BIND_ADDRESS));
+ if (configuredHost.isPresent()) {
+ try {
+ return InetAddress.getByName(configuredHost.get())
+ .getCanonicalHostName()
+ .toLowerCase(Locale.ROOT);
+ } catch (UnknownHostException e) {
+ throw new ConfigurationException(
+ "Could not resolve _HOST in JobManager web Kerberos principal.", e);
+ }
+ }
+ try {
+ return KerberosUtil.getLocalHostName().toLowerCase(Locale.ROOT);
+ } catch (UnknownHostException e) {
+ throw new ConfigurationException(
+ "Could not resolve local hostname for JobManager web Kerberos principal.", e);
+ }
+ }
+
+ @SafeVarargs
+ private static Optional firstUsableHost(Optional... hosts) {
+ for (Optional host : hosts) {
+ Optional usableHost =
+ host.map(String::trim)
+ .filter(value -> !value.isEmpty())
+ .filter(value -> !isWildcardAddress(value));
+ if (usableHost.isPresent()) {
+ return usableHost;
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static boolean isWildcardAddress(String value) {
+ return "0.0.0.0".equals(value) || "::".equals(value) || "[::]".equals(value);
+ }
+
+ private static Path resolveKeytab(String keytab) throws ConfigurationException {
+ Path keytabPath = Path.of(keytab);
+ if (!Files.isRegularFile(keytabPath) || !Files.isReadable(keytabPath)) {
+ throw new ConfigurationException(
+ AUTHENTICATION_KERBEROS_KEYTAB.key()
+ + " does not point to a readable regular file: "
+ + keytabPath);
+ }
+ return keytabPath;
+ }
+
+ private static Duration validateTokenValidity(Duration tokenValidity)
+ throws ConfigurationException {
+ if (tokenValidity == null || tokenValidity.isZero() || tokenValidity.isNegative()) {
+ throw new ConfigurationException(
+ AUTHENTICATION_TOKEN_VALIDITY.key() + " must be greater than 0.");
+ }
+ return tokenValidity;
+ }
+
+ private static String validateCookiePath(String cookiePath) throws ConfigurationException {
+ if (StringUtils.isNullOrWhitespaceOnly(cookiePath) || !cookiePath.startsWith("/")) {
+ throw new ConfigurationException(
+ AUTHENTICATION_COOKIE_PATH.key() + " must be a non-empty absolute path.");
+ }
+ return cookiePath;
+ }
+
+ private static byte[] resolveSignatureSecret(Configuration configuration)
+ throws ConfigurationException {
+ Optional configuredSecret =
+ optionalNonBlank(configuration, AUTHENTICATION_SIGNATURE_SECRET);
+ Optional configuredSecretFile =
+ optionalNonBlank(configuration, AUTHENTICATION_SIGNATURE_SECRET_FILE);
+
+ if (configuredSecret.isPresent() && configuredSecretFile.isPresent()) {
+ throw new ConfigurationException(
+ "Only one of "
+ + AUTHENTICATION_SIGNATURE_SECRET.key()
+ + " and "
+ + AUTHENTICATION_SIGNATURE_SECRET_FILE.key()
+ + " may be configured.");
+ }
+
+ if (configuredSecret.isPresent()) {
+ return configuredSecret.get().getBytes(StandardCharsets.UTF_8);
+ }
+ if (configuredSecretFile.isPresent()) {
+ return readSecretFile(configuredSecretFile.get());
+ }
+ return createRandomSecret();
+ }
+
+ private static byte[] readSecretFile(String secretFile) throws ConfigurationException {
+ try {
+ String secret = Files.readString(Path.of(secretFile), StandardCharsets.UTF_8).trim();
+ if (secret.isEmpty()) {
+ throw new ConfigurationException(
+ AUTHENTICATION_SIGNATURE_SECRET_FILE.key() + " must not be empty.");
+ }
+ return secret.getBytes(StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new ConfigurationException(
+ "Could not read "
+ + AUTHENTICATION_SIGNATURE_SECRET_FILE.key()
+ + ": "
+ + secretFile,
+ e);
+ }
+ }
+
+ private static byte[] createRandomSecret() {
+ byte[] secret = new byte[RANDOM_SECRET_BYTES];
+ new SecureRandom().nextBytes(secret);
+ return secret;
+ }
+
+ private static String required(
+ Configuration configuration, org.apache.flink.configuration.ConfigOption option)
+ throws ConfigurationException {
+ return optionalNonBlank(configuration, option)
+ .orElseThrow(
+ () ->
+ new ConfigurationException(
+ option.key()
+ + " must be configured when "
+ + AUTHENTICATION_TYPE.key()
+ + " is KERBEROS."));
+ }
+
+ private static Optional optionalNonBlank(
+ Configuration configuration,
+ org.apache.flink.configuration.ConfigOption option) {
+ return configuration
+ .getOptional(option)
+ .map(String::trim)
+ .filter(value -> !value.isEmpty());
+ }
+}
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationHandler.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationHandler.java
new file mode 100644
index 0000000000000..622379e2898e9
--- /dev/null
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationHandler.java
@@ -0,0 +1,502 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.util.ConfigurationException;
+
+import org.apache.flink.shaded.netty4.io.netty.buffer.Unpooled;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelDuplexHandler;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelFutureListener;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandler;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandlerContext;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelPromise;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.DefaultFullHttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.FullHttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.FullHttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaderNames;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaderValues;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaders;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpMethod;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpUtil;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpVersion;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.LastHttpContent;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.cookie.Cookie;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.cookie.ServerCookieDecoder;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.multipart.HttpPostRequestDecoder;
+import org.apache.flink.shaded.netty4.io.netty.util.AttributeKey;
+import org.apache.flink.shaded.netty4.io.netty.util.ReferenceCountUtil;
+
+import org.apache.hadoop.security.authentication.client.AuthenticatedURL;
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.time.Clock;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/** Netty handler that enforces JobManager web SPNEGO authentication. */
+public final class JobManagerWebAuthenticationHandler extends ChannelDuplexHandler {
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(JobManagerWebAuthenticationHandler.class);
+
+ private static final AttributeKey RESPONSE_AUTHENTICATION_ATTRIBUTE =
+ AttributeKey.valueOf("jobmanager-web-spnego-response-authentication");
+ private static final AttributeKey AUTHENTICATED_USER =
+ AttributeKey.valueOf("jobmanager-web-authenticated-user");
+ private static final AttributeKey PRE_AUTHENTICATED_TOKEN =
+ AttributeKey.valueOf("jobmanager-web-pre-authenticated-token");
+
+ private final SpnegoAuthenticator authenticator;
+ private final JobManagerAuthenticationTokenSigner tokenSigner;
+ private final String cookiePath;
+ private final boolean secureCookie;
+ private final Map responseHeaders;
+
+ JobManagerWebAuthenticationHandler(
+ SpnegoAuthenticator authenticator,
+ JobManagerAuthenticationTokenSigner tokenSigner,
+ String cookiePath,
+ boolean secureCookie,
+ Map responseHeaders) {
+ this.authenticator = checkNotNull(authenticator);
+ this.tokenSigner = checkNotNull(tokenSigner);
+ this.cookiePath = checkNotNull(cookiePath);
+ this.secureCookie = secureCookie;
+ this.responseHeaders = checkNotNull(responseHeaders);
+ }
+
+ static ChannelHandler createPreFileUploadHandler(
+ SpnegoAuthenticator authenticator,
+ JobManagerAuthenticationTokenSigner tokenSigner,
+ String cookiePath,
+ boolean secureCookie,
+ Map responseHeaders) {
+ return new PreFileUploadAuthenticationHandler(
+ authenticator, tokenSigner, cookiePath, secureCookie, responseHeaders);
+ }
+
+ public static Optional createFactory(
+ Configuration configuration, Map responseHeaders)
+ throws ConfigurationException {
+ Optional config =
+ JobManagerWebAuthenticationConfig.fromConfiguration(configuration);
+ if (!config.isPresent()) {
+ return Optional.empty();
+ }
+
+ JobManagerWebAuthenticationConfig authenticationConfig = config.get();
+ SpnegoAuthenticator authenticator =
+ JobManagerSpnegoAuthenticator.create(authenticationConfig);
+ JobManagerAuthenticationTokenSigner signer =
+ new JobManagerAuthenticationTokenSigner(
+ authenticationConfig.getSignatureSecret(),
+ authenticationConfig.getTokenValidity(),
+ Clock.systemUTC());
+
+ return Optional.of(
+ new Factory() {
+ @Override
+ public ChannelHandler createHandler() {
+ return new JobManagerWebAuthenticationHandler(
+ authenticator,
+ signer,
+ authenticationConfig.getCookiePath(),
+ authenticationConfig.isSecureCookie(),
+ responseHeaders);
+ }
+
+ @Override
+ public ChannelHandler createPreFileUploadHandler() {
+ return JobManagerWebAuthenticationHandler.createPreFileUploadHandler(
+ authenticator,
+ signer,
+ authenticationConfig.getCookiePath(),
+ authenticationConfig.isSecureCookie(),
+ responseHeaders);
+ }
+ });
+ }
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ if (!(msg instanceof FullHttpRequest)) {
+ ctx.fireChannelRead(msg);
+ return;
+ }
+
+ FullHttpRequest request = (FullHttpRequest) msg;
+ AuthenticationToken preAuthenticatedToken =
+ ctx.channel().attr(PRE_AUTHENTICATED_TOKEN).getAndSet(null);
+ if (preAuthenticatedToken != null) {
+ setAuthenticatedUser(ctx, preAuthenticatedToken);
+ try {
+ ctx.fireChannelRead(msg);
+ } finally {
+ clearAuthenticatedUser(ctx);
+ }
+ return;
+ }
+
+ Optional cookieToken =
+ getAuthenticationTokenFromCookie(request, tokenSigner);
+ if (cookieToken.isPresent()) {
+ setAuthenticatedUser(ctx, cookieToken.get());
+ try {
+ ctx.fireChannelRead(msg);
+ } finally {
+ clearAuthenticatedUser(ctx);
+ }
+ return;
+ }
+
+ try {
+ JobManagerSpnegoAuthenticationResult result =
+ authenticator.authenticate(
+ request.headers().get(HttpHeaderNames.AUTHORIZATION));
+ if (!result.isAuthenticated()) {
+ sendResponse(
+ ctx,
+ request,
+ HttpResponseStatus.UNAUTHORIZED,
+ result.authenticateHeader(),
+ true);
+ return;
+ }
+
+ AuthenticationToken authenticationToken = result.authenticationToken();
+ String signedToken =
+ tokenSigner.signToken(
+ authenticationToken.getUserName(), authenticationToken.getName());
+ ctx.channel()
+ .attr(RESPONSE_AUTHENTICATION_ATTRIBUTE)
+ .set(new ResponseAuthentication(signedToken, result.authenticateHeader()));
+ setAuthenticatedUser(ctx, authenticationToken);
+ try {
+ ctx.fireChannelRead(msg);
+ } finally {
+ clearAuthenticatedUser(ctx);
+ }
+ } catch (AuthenticationException e) {
+ LOG.debug("JobManager web SPNEGO authentication failed.", e);
+ clearAuthenticatedUser(ctx);
+ sendResponse(ctx, request, HttpResponseStatus.FORBIDDEN, null, true);
+ }
+ }
+
+ @Override
+ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
+ throws Exception {
+ if (msg instanceof HttpResponse) {
+ addResponseAuthentication(
+ ctx, ((HttpResponse) msg).headers(), cookiePath, secureCookie);
+ }
+ ctx.write(msg, promise);
+ }
+
+ private static Optional getAuthenticationTokenFromCookie(
+ HttpRequest request, JobManagerAuthenticationTokenSigner tokenSigner) {
+ for (String cookieHeader : request.headers().getAll(HttpHeaderNames.COOKIE)) {
+ try {
+ Set cookies = ServerCookieDecoder.STRICT.decode(cookieHeader);
+ for (Cookie cookie : cookies) {
+ if (AuthenticatedURL.AUTH_COOKIE.equals(cookie.name())) {
+ Optional token =
+ tokenSigner.verifyToken(cookie.value());
+ if (token.isPresent()) {
+ return token;
+ }
+ }
+ }
+ } catch (IllegalArgumentException e) {
+ LOG.debug("Ignoring malformed JobManager web Cookie header.", e);
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static void setAuthenticatedUser(
+ ChannelHandlerContext ctx, AuthenticationToken authenticationToken) {
+ ctx.channel()
+ .attr(AUTHENTICATED_USER)
+ .set(
+ new JobManagerAuthenticatedUser(
+ authenticationToken.getUserName(),
+ authenticationToken.getName(),
+ authenticationToken.getType()));
+ }
+
+ private static void clearAuthenticatedUser(ChannelHandlerContext ctx) {
+ ctx.channel().attr(AUTHENTICATED_USER).set(null);
+ }
+
+ public static Optional getAuthenticatedUser(
+ ChannelHandlerContext ctx) {
+ return Optional.ofNullable(ctx.channel().attr(AUTHENTICATED_USER).get());
+ }
+
+ private void sendResponse(
+ ChannelHandlerContext ctx,
+ HttpRequest request,
+ HttpResponseStatus status,
+ @Nullable String authenticateHeader,
+ boolean expireCookie) {
+ sendResponse(
+ ctx,
+ request,
+ status,
+ authenticateHeader,
+ expireCookie,
+ responseHeaders,
+ cookiePath,
+ secureCookie);
+ }
+
+ private static void sendResponse(
+ ChannelHandlerContext ctx,
+ HttpRequest request,
+ HttpResponseStatus status,
+ @Nullable String authenticateHeader,
+ boolean expireCookie,
+ Map responseHeaders,
+ String cookiePath,
+ boolean secureCookie) {
+ clearAuthenticatedUser(ctx);
+ FullHttpResponse response =
+ new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.EMPTY_BUFFER);
+ responseHeaders.forEach((name, value) -> response.headers().set(name, value));
+ response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
+ if (authenticateHeader != null) {
+ response.headers().set(HttpHeaderNames.WWW_AUTHENTICATE, authenticateHeader);
+ }
+ if (expireCookie) {
+ response.headers()
+ .add(
+ HttpHeaderNames.SET_COOKIE,
+ createExpiredAuthCookie(cookiePath, secureCookie));
+ }
+
+ boolean keepAlive = HttpUtil.isKeepAlive(request);
+ ReferenceCountUtil.release(request);
+ if (keepAlive) {
+ response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
+ ctx.writeAndFlush(response);
+ } else {
+ ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
+ }
+ }
+
+ private static void addResponseAuthentication(
+ ChannelHandlerContext ctx,
+ HttpHeaders headers,
+ String cookiePath,
+ boolean secureCookie) {
+ ResponseAuthentication responseAuthentication =
+ ctx.channel().attr(RESPONSE_AUTHENTICATION_ATTRIBUTE).getAndSet(null);
+ if (responseAuthentication != null) {
+ headers.add(
+ HttpHeaderNames.SET_COOKIE,
+ createAuthCookie(responseAuthentication, cookiePath, secureCookie));
+ if (responseAuthentication.authenticateHeader() != null) {
+ headers.set(
+ HttpHeaderNames.WWW_AUTHENTICATE,
+ responseAuthentication.authenticateHeader());
+ }
+ }
+ }
+
+ private static String createAuthCookie(
+ ResponseAuthentication responseAuthentication,
+ String cookiePath,
+ boolean secureCookie) {
+ StringBuilder cookie =
+ new StringBuilder(AuthenticatedURL.AUTH_COOKIE)
+ .append("=\"")
+ .append(responseAuthentication.signedToken())
+ .append("\"; Path=")
+ .append(cookiePath);
+ if (secureCookie) {
+ cookie.append("; Secure");
+ }
+ cookie.append("; HttpOnly");
+ return cookie.toString();
+ }
+
+ private static String createExpiredAuthCookie(String cookiePath, boolean secureCookie) {
+ StringBuilder cookie =
+ new StringBuilder(AuthenticatedURL.AUTH_COOKIE)
+ .append("=; Path=")
+ .append(cookiePath)
+ .append("; Max-Age=0");
+ if (secureCookie) {
+ cookie.append("; Secure");
+ }
+ cookie.append("; HttpOnly");
+ return cookie.toString();
+ }
+
+ private static final class PreFileUploadAuthenticationHandler extends ChannelDuplexHandler {
+
+ private final SpnegoAuthenticator authenticator;
+ private final JobManagerAuthenticationTokenSigner tokenSigner;
+ private final String cookiePath;
+ private final boolean secureCookie;
+ private final Map responseHeaders;
+
+ private boolean discardingRejectedRequest;
+
+ private PreFileUploadAuthenticationHandler(
+ SpnegoAuthenticator authenticator,
+ JobManagerAuthenticationTokenSigner tokenSigner,
+ String cookiePath,
+ boolean secureCookie,
+ Map responseHeaders) {
+ this.authenticator = checkNotNull(authenticator);
+ this.tokenSigner = checkNotNull(tokenSigner);
+ this.cookiePath = checkNotNull(cookiePath);
+ this.secureCookie = secureCookie;
+ this.responseHeaders = checkNotNull(responseHeaders);
+ }
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ if (discardingRejectedRequest) {
+ if (msg instanceof LastHttpContent) {
+ discardingRejectedRequest = false;
+ }
+ ReferenceCountUtil.release(msg);
+ return;
+ }
+
+ if (!(msg instanceof HttpRequest) || !requiresEarlyAuthentication((HttpRequest) msg)) {
+ ctx.fireChannelRead(msg);
+ return;
+ }
+
+ HttpRequest request = (HttpRequest) msg;
+ Optional cookieToken =
+ getAuthenticationTokenFromCookie(request, tokenSigner);
+ if (cookieToken.isPresent()) {
+ ctx.channel().attr(PRE_AUTHENTICATED_TOKEN).set(cookieToken.get());
+ ctx.fireChannelRead(msg);
+ return;
+ }
+
+ try {
+ JobManagerSpnegoAuthenticationResult result =
+ authenticator.authenticate(
+ request.headers().get(HttpHeaderNames.AUTHORIZATION));
+ if (!result.isAuthenticated()) {
+ rejectRequest(
+ ctx,
+ request,
+ HttpResponseStatus.UNAUTHORIZED,
+ result.authenticateHeader());
+ return;
+ }
+
+ AuthenticationToken authenticationToken = result.authenticationToken();
+ String signedToken =
+ tokenSigner.signToken(
+ authenticationToken.getUserName(), authenticationToken.getName());
+ ctx.channel().attr(PRE_AUTHENTICATED_TOKEN).set(authenticationToken);
+ ctx.channel()
+ .attr(RESPONSE_AUTHENTICATION_ATTRIBUTE)
+ .set(new ResponseAuthentication(signedToken, result.authenticateHeader()));
+ ctx.fireChannelRead(msg);
+ } catch (AuthenticationException e) {
+ LOG.debug("JobManager web SPNEGO upload authentication failed.", e);
+ rejectRequest(ctx, request, HttpResponseStatus.FORBIDDEN, null);
+ }
+ }
+
+ @Override
+ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
+ throws Exception {
+ if (msg instanceof HttpResponse) {
+ addResponseAuthentication(
+ ctx, ((HttpResponse) msg).headers(), cookiePath, secureCookie);
+ ctx.channel().attr(PRE_AUTHENTICATED_TOKEN).set(null);
+ }
+ ctx.write(msg, promise);
+ }
+
+ private void rejectRequest(
+ ChannelHandlerContext ctx,
+ HttpRequest request,
+ HttpResponseStatus status,
+ @Nullable String authenticateHeader) {
+ clearAuthenticatedUser(ctx);
+ ctx.channel().attr(PRE_AUTHENTICATED_TOKEN).set(null);
+ ctx.channel().attr(RESPONSE_AUTHENTICATION_ATTRIBUTE).set(null);
+ discardingRejectedRequest = !(request instanceof LastHttpContent);
+ sendResponse(
+ ctx,
+ request,
+ status,
+ authenticateHeader,
+ true,
+ responseHeaders,
+ cookiePath,
+ secureCookie);
+ }
+
+ private static boolean requiresEarlyAuthentication(HttpRequest request) {
+ return HttpMethod.POST.equals(request.method())
+ && HttpPostRequestDecoder.isMultipart(request);
+ }
+ }
+
+ /** Factory for per-channel JobManager web authentication handlers. */
+ public interface Factory {
+ ChannelHandler createHandler();
+
+ ChannelHandler createPreFileUploadHandler();
+ }
+
+ private static final class ResponseAuthentication {
+ private final String signedToken;
+ @Nullable private final String authenticateHeader;
+
+ private ResponseAuthentication(String signedToken, @Nullable String authenticateHeader) {
+ this.signedToken = signedToken;
+ this.authenticateHeader = authenticateHeader;
+ }
+
+ private String signedToken() {
+ return signedToken;
+ }
+
+ @Nullable
+ private String authenticateHeader() {
+ return authenticateHeader;
+ }
+ }
+}
diff --git a/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/SpnegoAuthenticator.java b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/SpnegoAuthenticator.java
new file mode 100644
index 0000000000000..c33d934a19596
--- /dev/null
+++ b/flink-runtime/src/main/java/org/apache/flink/runtime/webmonitor/security/SpnegoAuthenticator.java
@@ -0,0 +1,28 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+
+/** Authentication step contract used by the JobManager web SPNEGO handler. */
+interface SpnegoAuthenticator {
+
+ JobManagerSpnegoAuthenticationResult authenticate(String authorizationHeader)
+ throws AuthenticationException;
+}
diff --git a/flink-runtime/src/main/resources/META-INF/NOTICE b/flink-runtime/src/main/resources/META-INF/NOTICE
index c453ff63d3563..17400d996820f 100644
--- a/flink-runtime/src/main/resources/META-INF/NOTICE
+++ b/flink-runtime/src/main/resources/META-INF/NOTICE
@@ -7,3 +7,15 @@ The Apache Software Foundation (http://www.apache.org/).
This project bundles the following dependencies under the Apache Software License 2.0. (http://www.apache.org/licenses/LICENSE-2.0.txt)
- io.airlift:aircompressor:0.27
+- commons-codec:commons-codec:1.15
+- org.apache.hadoop.thirdparty:hadoop-shaded-guava:1.6.0.1-4.3.0-0
+- org.apache.hadoop:hadoop-auth:3.4.3.1-4.3.0-1
+- org.apache.httpcomponents:httpclient:4.5.13
+- org.apache.httpcomponents:httpcore:4.4.14
+- org.apache.kerby:kerb-core:2.0.3
+- org.apache.kerby:kerb-crypto:2.0.3
+- org.apache.kerby:kerb-util:2.0.3
+- org.apache.kerby:kerby-asn1:2.0.3
+- org.apache.kerby:kerby-config:2.0.3
+- org.apache.kerby:kerby-pkix:2.0.3
+- org.apache.kerby:kerby-util:2.0.3
diff --git a/flink-runtime/src/test/java/org/apache/flink/runtime/rest/util/TestRestServerEndpoint.java b/flink-runtime/src/test/java/org/apache/flink/runtime/rest/util/TestRestServerEndpoint.java
index c9b25fe17a57a..89bb429fb5d29 100644
--- a/flink-runtime/src/test/java/org/apache/flink/runtime/rest/util/TestRestServerEndpoint.java
+++ b/flink-runtime/src/test/java/org/apache/flink/runtime/rest/util/TestRestServerEndpoint.java
@@ -25,12 +25,16 @@
import org.apache.flink.runtime.rest.handler.RestHandlerSpecification;
import org.apache.flink.util.ConfigurationException;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandler;
import org.apache.flink.shaded.netty4.io.netty.channel.ChannelInboundHandler;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Collection;
import java.util.List;
import java.util.concurrent.CompletableFuture;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
/** Utility {@link RestServerEndpoint} for setting up a rest server with a given set of handlers. */
public class TestRestServerEndpoint extends RestServerEndpoint {
@@ -47,6 +51,10 @@ public static class Builder {
private final Configuration configuration;
private final List> handlers =
new ArrayList<>();
+ private final List> preFileUploadChannelHandlerFactories =
+ new ArrayList<>();
+ private final List> endpointSpecificChannelHandlerFactories =
+ new ArrayList<>();
private Builder(Configuration configuration) {
this.configuration = configuration;
@@ -63,8 +71,29 @@ public Builder withHandler(AbstractRestHandler, ?, ?, ?> handler) {
return this;
}
+ public Builder withEndpointSpecificChannelHandler(ChannelHandler channelHandler) {
+ this.endpointSpecificChannelHandlerFactories.add(() -> channelHandler);
+ return this;
+ }
+
+ public Builder withEndpointSpecificChannelHandlerFactory(
+ Supplier channelHandlerFactory) {
+ this.endpointSpecificChannelHandlerFactories.add(channelHandlerFactory);
+ return this;
+ }
+
+ public Builder withEndpointSpecificPreFileUploadChannelHandlerFactory(
+ Supplier channelHandlerFactory) {
+ this.preFileUploadChannelHandlerFactories.add(channelHandlerFactory);
+ return this;
+ }
+
public TestRestServerEndpoint build() throws IOException, ConfigurationException {
- return new TestRestServerEndpoint(configuration, handlers);
+ return new TestRestServerEndpoint(
+ configuration,
+ handlers,
+ preFileUploadChannelHandlerFactories,
+ endpointSpecificChannelHandlerFactories);
}
public TestRestServerEndpoint buildAndStart() throws Exception {
@@ -76,13 +105,19 @@ public TestRestServerEndpoint buildAndStart() throws Exception {
}
private final List> handlers;
+ private final List> preFileUploadChannelHandlerFactories;
+ private final List> endpointSpecificChannelHandlerFactories;
private TestRestServerEndpoint(
final Configuration configuration,
- final List> handlers)
+ final List> handlers,
+ final List> preFileUploadChannelHandlerFactories,
+ final List> endpointSpecificChannelHandlerFactories)
throws IOException, ConfigurationException {
super(configuration);
this.handlers = handlers;
+ this.preFileUploadChannelHandlerFactories = preFileUploadChannelHandlerFactories;
+ this.endpointSpecificChannelHandlerFactories = endpointSpecificChannelHandlerFactories;
}
@Override
@@ -91,6 +126,20 @@ protected List> initiali
return this.handlers;
}
+ @Override
+ protected Collection createEndpointSpecificChannelHandlers() {
+ return endpointSpecificChannelHandlerFactories.stream()
+ .map(Supplier::get)
+ .collect(Collectors.toList());
+ }
+
+ @Override
+ protected Collection createEndpointSpecificPreFileUploadChannelHandlers() {
+ return preFileUploadChannelHandlerFactories.stream()
+ .map(Supplier::get)
+ .collect(Collectors.toList());
+ }
+
@Override
protected void startInternal() {}
}
diff --git a/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/WebMonitorEndpointTest.java b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/WebMonitorEndpointTest.java
index 3ad06659d8264..ab94f68d39b1a 100644
--- a/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/WebMonitorEndpointTest.java
+++ b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/WebMonitorEndpointTest.java
@@ -27,10 +27,15 @@
import org.apache.flink.runtime.rest.handler.RestHandlerConfiguration;
import org.apache.flink.runtime.rest.handler.legacy.metrics.VoidMetricFetcher;
import org.apache.flink.runtime.util.TestingFatalErrorHandler;
+import org.apache.flink.runtime.webmonitor.security.JobManagerAuthenticatedUserHeaders;
+import org.apache.flink.util.ConfigurationException;
import org.apache.flink.util.ExecutorUtils;
import org.junit.jupiter.api.Test;
+import java.net.HttpURLConnection;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -38,6 +43,9 @@
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
/** Tests for the {@link WebMonitorEndpoint}. */
class WebMonitorEndpointTest {
@@ -45,6 +53,7 @@ class WebMonitorEndpointTest {
void cleansUpExpiredExecutionGraphs() throws Exception {
final Configuration configuration = new Configuration();
configuration.set(RestOptions.ADDRESS, "localhost");
+ configuration.set(RestOptions.BIND_PORT, "0");
configuration.set(WebOptions.REFRESH_INTERVAL, Duration.ofMillis(5L));
final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
final long timeout = 10000L;
@@ -75,4 +84,83 @@ void cleansUpExpiredExecutionGraphs() throws Exception {
ExecutorUtils.gracefulShutdown(timeout, TimeUnit.MILLISECONDS, executor);
}
}
+
+ @Test
+ void exposesAuthenticatedUserEndpointWhenWebAuthenticationIsDisabled() throws Exception {
+ final Configuration configuration = new Configuration();
+ configuration.set(RestOptions.ADDRESS, "localhost");
+ configuration.set(RestOptions.BIND_PORT, "0");
+ final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
+ final long timeout = 10000L;
+
+ try (final WebMonitorEndpoint webMonitorEndpoint =
+ new WebMonitorEndpoint<>(
+ CompletableFuture::new,
+ configuration,
+ RestHandlerConfiguration.fromConfiguration(configuration),
+ CompletableFuture::new,
+ NoOpTransientBlobService.INSTANCE,
+ executor,
+ VoidMetricFetcher.INSTANCE,
+ new StandaloneLeaderElection(UUID.randomUUID()),
+ TestingExecutionGraphCache.newBuilder().build(),
+ new TestingFatalErrorHandler())) {
+
+ webMonitorEndpoint.start();
+
+ HttpURLConnection connection =
+ (HttpURLConnection)
+ new URI(
+ "http",
+ null,
+ "localhost",
+ webMonitorEndpoint.getServerAddress().getPort(),
+ JobManagerAuthenticatedUserHeaders.URL,
+ null,
+ null)
+ .toURL()
+ .openConnection();
+ connection.setRequestMethod("GET");
+
+ assertThat(connection.getResponseCode()).isEqualTo(200);
+ assertThat(
+ new String(
+ connection.getInputStream().readAllBytes(),
+ StandardCharsets.UTF_8))
+ .contains("\"authenticated\":false");
+ } finally {
+ ExecutorUtils.gracefulShutdown(timeout, TimeUnit.MILLISECONDS, executor);
+ }
+ }
+
+ @Test
+ void failsFastForInvalidKerberosWebAuthenticationConfiguration() throws Exception {
+ final Configuration configuration = new Configuration();
+ configuration.set(RestOptions.ADDRESS, "localhost");
+ configuration.set(
+ WebOptions.AUTHENTICATION_TYPE, WebOptions.WebAuthenticationType.KERBEROS);
+ final ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
+ final long timeout = 10000L;
+
+ try {
+ assertThatThrownBy(
+ () ->
+ new WebMonitorEndpoint<>(
+ CompletableFuture::new,
+ configuration,
+ RestHandlerConfiguration.fromConfiguration(
+ configuration),
+ CompletableFuture::new,
+ NoOpTransientBlobService.INSTANCE,
+ executor,
+ VoidMetricFetcher.INSTANCE,
+ new StandaloneLeaderElection(UUID.randomUUID()),
+ TestingExecutionGraphCache.newBuilder().build(),
+ new TestingFatalErrorHandler()))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining(WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL.key());
+ } finally {
+ ExecutorUtils.gracefulShutdown(timeout, TimeUnit.MILLISECONDS, executor);
+ }
+ }
}
diff --git a/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUserHandlerTest.java b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUserHandlerTest.java
new file mode 100644
index 0000000000000..b2151a6a9cc8e
--- /dev/null
+++ b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticatedUserHandlerTest.java
@@ -0,0 +1,162 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.runtime.rest.handler.router.RouteResult;
+import org.apache.flink.runtime.rest.handler.router.RoutedRequest;
+
+import org.apache.flink.shaded.netty4.io.netty.buffer.ByteBuf;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandlerContext;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelInboundHandlerAdapter;
+import org.apache.flink.shaded.netty4.io.netty.channel.embedded.EmbeddedChannel;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.DefaultFullHttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.FullHttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaderNames;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpMethod;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpVersion;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.LastHttpContent;
+import org.apache.flink.shaded.netty4.io.netty.util.ReferenceCountUtil;
+
+import org.apache.hadoop.security.authentication.client.AuthenticatedURL;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link JobManagerAuthenticatedUserHandler}. */
+class JobManagerAuthenticatedUserHandlerTest {
+
+ private static final Clock NOW = Clock.fixed(Instant.ofEpochMilli(10_000L), ZoneOffset.UTC);
+ private static final byte[] SECRET = "secret".getBytes(StandardCharsets.UTF_8);
+
+ @Test
+ void shouldReturnUnauthenticatedResponseWhenAuthenticationIsDisabled() {
+ EmbeddedChannel channel = new EmbeddedChannel(new JobManagerAuthenticatedUserHandler());
+ DefaultFullHttpRequest request =
+ new DefaultFullHttpRequest(
+ HttpVersion.HTTP_1_1,
+ HttpMethod.GET,
+ JobManagerAuthenticatedUserHeaders.URL);
+
+ try {
+ assertThat(channel.writeInbound(routedRequest(request))).isFalse();
+
+ HttpResponse response = channel.readOutbound();
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.OK);
+ assertThat(response.headers().get(HttpHeaderNames.CACHE_CONTROL)).isEqualTo("no-store");
+ ByteBuf content = channel.readOutbound();
+ LastHttpContent lastContent = channel.readOutbound();
+ try {
+ assertThat(content.toString(StandardCharsets.UTF_8))
+ .contains("\"authenticated\":false");
+ assertThat(lastContent).isSameAs(LastHttpContent.EMPTY_LAST_CONTENT);
+ } finally {
+ ReferenceCountUtil.release(content);
+ ReferenceCountUtil.release(lastContent);
+ }
+ } finally {
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldReturnAuthenticatedUserFromCurrentRequest() {
+ JobManagerAuthenticationTokenSigner signer =
+ new JobManagerAuthenticationTokenSigner(SECRET, Duration.ofHours(1), NOW);
+ JobManagerAuthenticatedUserHandler authenticatedUserHandler =
+ new JobManagerAuthenticatedUserHandler();
+ EmbeddedChannel channel =
+ new EmbeddedChannel(
+ new JobManagerWebAuthenticationHandler(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.challenge("Negotiate"),
+ signer,
+ "/",
+ false,
+ Collections.emptyMap()),
+ new ChannelInboundHandlerAdapter() {
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg)
+ throws Exception {
+ if (msg instanceof FullHttpRequest) {
+ authenticatedUserHandler.channelRead(
+ ctx, routedRequest((FullHttpRequest) msg));
+ } else {
+ super.channelRead(ctx, msg);
+ }
+ }
+ });
+ DefaultFullHttpRequest request =
+ new DefaultFullHttpRequest(
+ HttpVersion.HTTP_1_1,
+ HttpMethod.GET,
+ JobManagerAuthenticatedUserHeaders.URL);
+ request.headers()
+ .set(
+ HttpHeaderNames.COOKIE,
+ AuthenticatedURL.AUTH_COOKIE
+ + "=\""
+ + signer.signToken("alice", "alice@EXAMPLE.COM")
+ + "\"");
+
+ try {
+ assertThat(channel.writeInbound(request)).isFalse();
+
+ HttpResponse response = channel.readOutbound();
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.OK);
+ ByteBuf content = channel.readOutbound();
+ LastHttpContent lastContent = channel.readOutbound();
+ try {
+ assertThat(content.toString(StandardCharsets.UTF_8))
+ .contains("\"authenticated\":true")
+ .contains("\"user\":\"alice\"")
+ .contains("\"principal\":\"alice@EXAMPLE.COM\"")
+ .contains(
+ "\"type\":\""
+ + JobManagerAuthenticationTokenSigner.TOKEN_TYPE_KERBEROS
+ + "\"");
+ assertThat(lastContent).isSameAs(LastHttpContent.EMPTY_LAST_CONTENT);
+ } finally {
+ ReferenceCountUtil.release(content);
+ ReferenceCountUtil.release(lastContent);
+ }
+ } finally {
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ private static RoutedRequest routedRequest(FullHttpRequest request) {
+ return new RoutedRequest<>(
+ new RouteResult<>(
+ request.uri(),
+ request.uri(),
+ Collections.emptyMap(),
+ Collections.emptyMap(),
+ new JobManagerAuthenticatedUserHandler()),
+ request);
+ }
+}
diff --git a/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticationTokenSignerTest.java b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticationTokenSignerTest.java
new file mode 100644
index 0000000000000..2b2ffe518e261
--- /dev/null
+++ b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerAuthenticationTokenSignerTest.java
@@ -0,0 +1,88 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link JobManagerAuthenticationTokenSigner}. */
+class JobManagerAuthenticationTokenSignerTest {
+
+ private static final byte[] SECRET = "shared-secret".getBytes(StandardCharsets.UTF_8);
+ private static final Clock NOW = Clock.fixed(Instant.ofEpochMilli(10_000L), ZoneOffset.UTC);
+
+ @Test
+ void shouldSignAndVerifyAuthenticationToken() {
+ JobManagerAuthenticationTokenSigner signer = signer(NOW, Duration.ofHours(1));
+
+ Optional token =
+ signer.verifyToken(signer.signToken("alice", "alice@EXAMPLE.COM"));
+
+ assertThat(token)
+ .hasValueSatisfying(
+ authenticationToken -> {
+ assertThat(authenticationToken.getUserName()).isEqualTo("alice");
+ assertThat(authenticationToken.getName())
+ .isEqualTo("alice@EXAMPLE.COM");
+ assertThat(authenticationToken.getType())
+ .isEqualTo(
+ JobManagerAuthenticationTokenSigner
+ .TOKEN_TYPE_KERBEROS);
+ });
+ }
+
+ @Test
+ void shouldRejectInvalidSignature() {
+ JobManagerAuthenticationTokenSigner signer = signer(NOW, Duration.ofHours(1));
+ String signedToken = signer.signToken("alice", "alice@EXAMPLE.COM");
+
+ assertThat(signer.verifyToken(signedToken + "tampered")).isEmpty();
+ }
+
+ @Test
+ void shouldRejectExpiredToken() {
+ JobManagerAuthenticationTokenSigner signer = signer(NOW, Duration.ofMillis(1));
+ String signedToken = signer.signToken("alice", "alice@EXAMPLE.COM");
+ JobManagerAuthenticationTokenSigner verifier =
+ signer(Clock.offset(NOW, Duration.ofMillis(2)), Duration.ofMillis(1));
+
+ assertThat(verifier.verifyToken(signedToken)).isEmpty();
+ }
+
+ @Test
+ void shouldAcceptQuotedCookieValue() {
+ JobManagerAuthenticationTokenSigner signer = signer(NOW, Duration.ofHours(1));
+ String signedToken = signer.signToken("alice", "alice@EXAMPLE.COM");
+
+ assertThat(signer.verifyToken("\"" + signedToken + "\"")).isPresent();
+ }
+
+ private static JobManagerAuthenticationTokenSigner signer(Clock clock, Duration tokenValidity) {
+ return new JobManagerAuthenticationTokenSigner(SECRET, tokenValidity, clock);
+ }
+}
diff --git a/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationConfigTest.java b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationConfigTest.java
new file mode 100644
index 0000000000000..3996ed8693c3e
--- /dev/null
+++ b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationConfigTest.java
@@ -0,0 +1,226 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.RestOptions;
+import org.apache.flink.configuration.WebOptions;
+import org.apache.flink.configuration.WebOptions.WebAuthenticationType;
+import org.apache.flink.util.ConfigurationException;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link JobManagerWebAuthenticationConfig}. */
+class JobManagerWebAuthenticationConfigTest {
+
+ @TempDir private Path tempDir;
+
+ @Test
+ void shouldReturnEmptyConfigWhenAuthenticationIsDisabled() throws Exception {
+ assertThat(JobManagerWebAuthenticationConfig.fromConfiguration(new Configuration()))
+ .isEmpty();
+ }
+
+ @Test
+ void shouldRejectInvalidAuthenticationType() {
+ Configuration configuration = new Configuration();
+ configuration.setString(WebOptions.AUTHENTICATION_TYPE.key(), "BASIC");
+
+ assertThatThrownBy(() -> JobManagerWebAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining(WebOptions.AUTHENTICATION_TYPE.key());
+ }
+
+ @Test
+ void shouldRequirePrincipalWhenKerberosIsEnabled() throws Exception {
+ Configuration configuration = kerberosConfiguration();
+ configuration.set(WebOptions.AUTHENTICATION_KERBEROS_KEYTAB, createKeytab().toString());
+
+ assertThatThrownBy(() -> JobManagerWebAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining(WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL.key());
+ }
+
+ @Test
+ void shouldRequireKeytabWhenKerberosIsEnabled() {
+ Configuration configuration = kerberosConfiguration();
+ configuration.set(
+ WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL, "HTTP/localhost@EXAMPLE.COM");
+
+ assertThatThrownBy(() -> JobManagerWebAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining(WebOptions.AUTHENTICATION_KERBEROS_KEYTAB.key());
+ }
+
+ @Test
+ void shouldResolveHostPlaceholderInPrincipalFromRestAddress() throws Exception {
+ Configuration configuration = validKerberosConfiguration();
+ configuration.set(WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL, "HTTP/_HOST@EXAMPLE.COM");
+ configuration.set(RestOptions.ADDRESS, "localhost");
+
+ JobManagerWebAuthenticationConfig config =
+ JobManagerWebAuthenticationConfig.fromConfiguration(configuration).orElseThrow();
+
+ assertThat(config.getKerberosPrincipal())
+ .startsWith("HTTP/")
+ .endsWith("@EXAMPLE.COM")
+ .doesNotContain("_HOST");
+ }
+
+ @Test
+ void shouldResolveHostPlaceholderFromBindAddressWhenRestAddressIsWildcard() throws Exception {
+ Configuration configuration = validKerberosConfiguration();
+ configuration.set(WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL, "HTTP/_HOST@EXAMPLE.COM");
+ configuration.set(RestOptions.ADDRESS, "0.0.0.0");
+ configuration.set(RestOptions.BIND_ADDRESS, "localhost");
+
+ JobManagerWebAuthenticationConfig config =
+ JobManagerWebAuthenticationConfig.fromConfiguration(configuration).orElseThrow();
+
+ assertThat(config.getKerberosPrincipal())
+ .startsWith("HTTP/")
+ .endsWith("@EXAMPLE.COM")
+ .doesNotContain("_HOST")
+ .doesNotContain("0.0.0.0");
+ }
+
+ @Test
+ void shouldAllowWildcardPrincipal() throws Exception {
+ Configuration configuration = validKerberosConfiguration();
+ configuration.set(WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL, "*");
+
+ JobManagerWebAuthenticationConfig config =
+ JobManagerWebAuthenticationConfig.fromConfiguration(configuration).orElseThrow();
+
+ assertThat(config.getKerberosPrincipal()).isEqualTo("*");
+ }
+
+ @Test
+ void shouldUseDefaultNameRules() throws Exception {
+ JobManagerWebAuthenticationConfig config =
+ JobManagerWebAuthenticationConfig.fromConfiguration(validKerberosConfiguration())
+ .orElseThrow();
+
+ assertThat(config.getKerberosNameRules()).isEqualTo("DEFAULT");
+ }
+
+ @Test
+ void shouldRejectNonHttpPrincipal() throws Exception {
+ Configuration configuration = validKerberosConfiguration();
+ configuration.set(
+ WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL, "flink/localhost@EXAMPLE.COM");
+
+ assertThatThrownBy(() -> JobManagerWebAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("must start with HTTP/");
+ }
+
+ @Test
+ void shouldRejectInvalidCookiePath() throws Exception {
+ Configuration configuration = validKerberosConfiguration();
+ configuration.set(WebOptions.AUTHENTICATION_COOKIE_PATH, "flink");
+
+ assertThatThrownBy(() -> JobManagerWebAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining(WebOptions.AUTHENTICATION_COOKIE_PATH.key());
+ }
+
+ @Test
+ void shouldRejectNonPositiveTokenValidity() throws Exception {
+ Configuration configuration = validKerberosConfiguration();
+ configuration.set(WebOptions.AUTHENTICATION_TOKEN_VALIDITY, Duration.ZERO);
+
+ assertThatThrownBy(() -> JobManagerWebAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining(WebOptions.AUTHENTICATION_TOKEN_VALIDITY.key());
+ }
+
+ @Test
+ void shouldRejectUnreadableKeytabPath() throws Exception {
+ Configuration configuration = kerberosConfiguration();
+ configuration.set(
+ WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL, "HTTP/localhost@EXAMPLE.COM");
+ configuration.set(
+ WebOptions.AUTHENTICATION_KERBEROS_KEYTAB,
+ tempDir.resolve("missing.keytab").toString());
+
+ assertThatThrownBy(() -> JobManagerWebAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining(WebOptions.AUTHENTICATION_KERBEROS_KEYTAB.key());
+ }
+
+ @Test
+ void shouldRejectMultipleSignatureSecretSources() throws Exception {
+ Configuration configuration = validKerberosConfiguration();
+ configuration.set(WebOptions.AUTHENTICATION_SIGNATURE_SECRET, "secret");
+ configuration.set(
+ WebOptions.AUTHENTICATION_SIGNATURE_SECRET_FILE,
+ createSecretFile("secret").toString());
+
+ assertThatThrownBy(() -> JobManagerWebAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("Only one of");
+ }
+
+ @Test
+ void shouldLoadSignatureSecretFromFile() throws Exception {
+ Configuration configuration = validKerberosConfiguration();
+ configuration.set(
+ WebOptions.AUTHENTICATION_SIGNATURE_SECRET_FILE,
+ createSecretFile("shared-secret\n").toString());
+
+ JobManagerWebAuthenticationConfig config =
+ JobManagerWebAuthenticationConfig.fromConfiguration(configuration).orElseThrow();
+
+ assertThat(new String(config.getSignatureSecret(), StandardCharsets.UTF_8))
+ .isEqualTo("shared-secret");
+ }
+
+ private Configuration validKerberosConfiguration() throws Exception {
+ Configuration configuration = kerberosConfiguration();
+ configuration.set(
+ WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL, "HTTP/localhost@EXAMPLE.COM");
+ configuration.set(WebOptions.AUTHENTICATION_KERBEROS_KEYTAB, createKeytab().toString());
+ return configuration;
+ }
+
+ private static Configuration kerberosConfiguration() {
+ Configuration configuration = new Configuration();
+ configuration.set(WebOptions.AUTHENTICATION_TYPE, WebAuthenticationType.KERBEROS);
+ return configuration;
+ }
+
+ private Path createKeytab() throws Exception {
+ return Files.createFile(tempDir.resolve("test-" + System.nanoTime() + ".keytab"));
+ }
+
+ private Path createSecretFile(String secret) throws Exception {
+ Path secretFile = tempDir.resolve("secret-" + System.nanoTime());
+ return Files.write(secretFile, secret.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationHandlerTest.java b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationHandlerTest.java
new file mode 100644
index 0000000000000..d1ab609c3d8a7
--- /dev/null
+++ b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebAuthenticationHandlerTest.java
@@ -0,0 +1,519 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.configuration.Configuration;
+
+import org.apache.flink.shaded.netty4.io.netty.buffer.Unpooled;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandler;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandlerContext;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelInboundHandlerAdapter;
+import org.apache.flink.shaded.netty4.io.netty.channel.embedded.EmbeddedChannel;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.DefaultFullHttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.DefaultFullHttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.DefaultHttpContent;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.DefaultHttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.FullHttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.FullHttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaderNames;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpMethod;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpVersion;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.LastHttpContent;
+import org.apache.flink.shaded.netty4.io.netty.util.ReferenceCountUtil;
+
+import org.apache.hadoop.security.authentication.client.AuthenticatedURL;
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+import org.junit.jupiter.api.Test;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link JobManagerWebAuthenticationHandler}. */
+class JobManagerWebAuthenticationHandlerTest {
+
+ private static final byte[] SECRET = "secret".getBytes(StandardCharsets.UTF_8);
+ private static final Clock NOW = Clock.fixed(Instant.ofEpochMilli(10_000L), ZoneOffset.UTC);
+
+ @Test
+ void shouldNotCreateHandlerWhenAuthenticationIsDisabled() throws Exception {
+ assertThat(
+ JobManagerWebAuthenticationHandler.createFactory(
+ new Configuration(), Collections.emptyMap()))
+ .isEmpty();
+ }
+
+ @Test
+ void shouldChallengeUnauthenticatedRequest() {
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.challenge("Negotiate"));
+
+ channel.writeInbound(request());
+
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.UNAUTHORIZED);
+ assertThat(response.headers().get(HttpHeaderNames.WWW_AUTHENTICATE))
+ .isEqualTo("Negotiate");
+ assertThat(response.headers().getInt(HttpHeaderNames.CONTENT_LENGTH)).isZero();
+ assertThat(response.headers().getAll(HttpHeaderNames.SET_COOKIE))
+ .anySatisfy(
+ cookie ->
+ assertThat(cookie)
+ .startsWith(AuthenticatedURL.AUTH_COOKIE + "=")
+ .contains("Max-Age=0")
+ .contains("HttpOnly"));
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldRejectInvalidSpnegoToken() {
+ EmbeddedChannel channel =
+ channel(
+ authorization -> {
+ throw new AuthenticationException("bad token");
+ });
+
+ FullHttpRequest request = request();
+ request.headers().set(HttpHeaderNames.AUTHORIZATION, "Negotiate invalid");
+ channel.writeInbound(request);
+
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.FORBIDDEN);
+ assertThat(response.headers().contains(HttpHeaderNames.WWW_AUTHENTICATE)).isFalse();
+ assertThat(response.headers().getAll(HttpHeaderNames.SET_COOKIE))
+ .anySatisfy(
+ cookie ->
+ assertThat(cookie)
+ .startsWith(AuthenticatedURL.AUTH_COOKIE + "=")
+ .contains("Max-Age=0"));
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldPassThroughValidCookie() {
+ JobManagerAuthenticationTokenSigner signer = signer(NOW, Duration.ofHours(1));
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.challenge("Negotiate"),
+ signer,
+ false,
+ Collections.emptyMap());
+
+ FullHttpRequest request = request();
+ request.headers()
+ .set(
+ HttpHeaderNames.COOKIE,
+ AuthenticatedURL.AUTH_COOKIE
+ + "=\""
+ + signer.signToken("alice", "alice@EXAMPLE.COM")
+ + "\"");
+ channel.writeInbound(request);
+
+ FullHttpRequest forwarded = channel.readInbound();
+ try {
+ assertThat(forwarded.uri()).isEqualTo("/jobs/overview");
+ assertThat((Object) channel.readOutbound()).isNull();
+ } finally {
+ forwarded.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldExposeAuthenticatedUserWhileHandlingRequestWithValidSignedCookie() {
+ AtomicReference> authenticatedUser =
+ new AtomicReference<>(Optional.empty());
+ JobManagerAuthenticationTokenSigner signer = signer(NOW, Duration.ofHours(1));
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.challenge("Negotiate"),
+ signer,
+ false,
+ Collections.emptyMap(),
+ new ChannelInboundHandlerAdapter() {
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) {
+ authenticatedUser.set(
+ JobManagerWebAuthenticationHandler.getAuthenticatedUser(
+ ctx));
+ ReferenceCountUtil.release(msg);
+ }
+ });
+
+ FullHttpRequest request = request();
+ request.headers()
+ .set(
+ HttpHeaderNames.COOKIE,
+ AuthenticatedURL.AUTH_COOKIE
+ + "=\""
+ + signer.signToken("alice", "alice@EXAMPLE.COM")
+ + "\"");
+ channel.writeInbound(request);
+
+ try {
+ assertThat(authenticatedUser.get())
+ .hasValueSatisfying(
+ user -> {
+ assertThat(user.getUserName()).isEqualTo("alice");
+ assertThat(user.getPrincipal()).isEqualTo("alice@EXAMPLE.COM");
+ assertThat(user.getType())
+ .isEqualTo(
+ JobManagerAuthenticationTokenSigner
+ .TOKEN_TYPE_KERBEROS);
+ });
+ assertThat(
+ JobManagerWebAuthenticationHandler.getAuthenticatedUser(
+ channel.pipeline().firstContext()))
+ .isEmpty();
+ } finally {
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldIgnoreExpiredCookieAndRestartChallenge() {
+ JobManagerAuthenticationTokenSigner signer = signer(NOW, Duration.ofMillis(1));
+ String expiredToken = signer.signToken("alice", "alice@EXAMPLE.COM");
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.challenge("Negotiate"),
+ signer(Clock.offset(NOW, Duration.ofMillis(2)), Duration.ofMillis(1)),
+ false,
+ Collections.emptyMap());
+
+ FullHttpRequest request = request();
+ request.headers()
+ .set(
+ HttpHeaderNames.COOKIE,
+ AuthenticatedURL.AUTH_COOKIE + "=\"" + expiredToken + "\"");
+ channel.writeInbound(request);
+
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.UNAUTHORIZED);
+ assertThat(response.headers().get(HttpHeaderNames.WWW_AUTHENTICATE))
+ .isEqualTo("Negotiate");
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldIssueCookieAfterSuccessfulSpnegoAuthentication() {
+ AtomicReference> authenticatedUser =
+ new AtomicReference<>(Optional.empty());
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.authenticated(
+ new AuthenticationToken(
+ "alice",
+ "alice@EXAMPLE.COM",
+ JobManagerAuthenticationTokenSigner
+ .TOKEN_TYPE_KERBEROS),
+ "Negotiate server-token"),
+ signer(NOW, Duration.ofHours(1)),
+ false,
+ Collections.emptyMap(),
+ new ChannelInboundHandlerAdapter() {
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) {
+ authenticatedUser.set(
+ JobManagerWebAuthenticationHandler.getAuthenticatedUser(
+ ctx));
+ ReferenceCountUtil.release(msg);
+ }
+ });
+
+ FullHttpRequest request = request();
+ request.headers().set(HttpHeaderNames.AUTHORIZATION, "Negotiate client-token");
+ channel.writeInbound(request);
+
+ channel.writeOutbound(
+ new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK));
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(authenticatedUser.get())
+ .hasValueSatisfying(
+ user -> {
+ assertThat(user.getUserName()).isEqualTo("alice");
+ assertThat(user.getPrincipal()).isEqualTo("alice@EXAMPLE.COM");
+ assertThat(user.getType())
+ .isEqualTo(
+ JobManagerAuthenticationTokenSigner
+ .TOKEN_TYPE_KERBEROS);
+ });
+ assertThat(response.headers().get(HttpHeaderNames.SET_COOKIE))
+ .contains(AuthenticatedURL.AUTH_COOKIE + "=\"")
+ .contains("; Path=/")
+ .contains("; HttpOnly");
+ assertThat(response.headers().get(HttpHeaderNames.WWW_AUTHENTICATE))
+ .isEqualTo("Negotiate server-token");
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldMarkAuthenticationCookieSecureWhenSslIsEnabled() {
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.authenticated(
+ new AuthenticationToken(
+ "alice",
+ "alice@EXAMPLE.COM",
+ JobManagerAuthenticationTokenSigner
+ .TOKEN_TYPE_KERBEROS),
+ null),
+ signer(NOW, Duration.ofHours(1)),
+ true,
+ Collections.emptyMap());
+
+ FullHttpRequest request = request();
+ request.headers().set(HttpHeaderNames.AUTHORIZATION, "Negotiate client-token");
+ channel.writeInbound(request);
+ FullHttpRequest forwarded = channel.readInbound();
+ forwarded.release();
+
+ channel.writeOutbound(
+ new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK));
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.headers().get(HttpHeaderNames.SET_COOKIE)).contains("; Secure");
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldIncludeConfiguredResponseHeadersInChallenge() {
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.challenge("Negotiate"),
+ signer(NOW, Duration.ofHours(1)),
+ false,
+ Collections.singletonMap("Access-Control-Allow-Origin", "example.com"));
+
+ channel.writeInbound(request());
+
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.headers().get("Access-Control-Allow-Origin"))
+ .isEqualTo("example.com");
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldChallengeMultipartUploadBeforeBodyIsForwarded() {
+ AtomicReference forwarded = new AtomicReference<>();
+ EmbeddedChannel channel =
+ preFileUploadChannel(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.challenge("Negotiate"),
+ signer(NOW, Duration.ofHours(1)),
+ false,
+ Collections.emptyMap(),
+ forwardingRecorder(forwarded));
+
+ channel.writeInbound(multipartUploadRequest());
+ channel.writeInbound(
+ new DefaultHttpContent(
+ Unpooled.copiedBuffer("file-content", StandardCharsets.UTF_8)));
+ channel.writeInbound(LastHttpContent.EMPTY_LAST_CONTENT);
+
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.UNAUTHORIZED);
+ assertThat(response.headers().get(HttpHeaderNames.WWW_AUTHENTICATE))
+ .isEqualTo("Negotiate");
+ assertThat(forwarded.get()).isNull();
+ assertThat((Object) channel.readInbound()).isNull();
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldRejectInvalidMultipartSpnegoBeforeBodyIsForwarded() {
+ AtomicReference forwarded = new AtomicReference<>();
+ EmbeddedChannel channel =
+ preFileUploadChannel(
+ authorization -> {
+ throw new AuthenticationException("bad token");
+ },
+ signer(NOW, Duration.ofHours(1)),
+ false,
+ Collections.emptyMap(),
+ forwardingRecorder(forwarded));
+
+ HttpRequest request = multipartUploadRequest();
+ request.headers().set(HttpHeaderNames.AUTHORIZATION, "Negotiate invalid");
+ channel.writeInbound(request);
+ channel.writeInbound(
+ new DefaultHttpContent(
+ Unpooled.copiedBuffer("file-content", StandardCharsets.UTF_8)));
+ channel.writeInbound(LastHttpContent.EMPTY_LAST_CONTENT);
+
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.FORBIDDEN);
+ assertThat(forwarded.get()).isNull();
+ assertThat((Object) channel.readInbound()).isNull();
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldPassThroughMultipartUploadWithValidCookie() {
+ AtomicReference forwarded = new AtomicReference<>();
+ JobManagerAuthenticationTokenSigner signer = signer(NOW, Duration.ofHours(1));
+ EmbeddedChannel channel =
+ preFileUploadChannel(
+ authorization ->
+ JobManagerSpnegoAuthenticationResult.challenge("Negotiate"),
+ signer,
+ false,
+ Collections.emptyMap(),
+ forwardingRecorder(forwarded));
+
+ HttpRequest request = multipartUploadRequest();
+ request.headers()
+ .set(
+ HttpHeaderNames.COOKIE,
+ AuthenticatedURL.AUTH_COOKIE
+ + "=\""
+ + signer.signToken("alice", "alice@EXAMPLE.COM")
+ + "\"");
+ channel.writeInbound(request);
+
+ try {
+ assertThat(forwarded.get()).isInstanceOf(HttpRequest.class);
+ assertThat(((HttpRequest) forwarded.get()).uri()).isEqualTo("/jars/upload");
+ assertThat((Object) channel.readOutbound()).isNull();
+ } finally {
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ private static EmbeddedChannel channel(SpnegoAuthenticator authenticator) {
+ return channel(
+ authenticator, signer(NOW, Duration.ofHours(1)), false, Collections.emptyMap());
+ }
+
+ private static EmbeddedChannel channel(
+ SpnegoAuthenticator authenticator,
+ JobManagerAuthenticationTokenSigner signer,
+ boolean secureCookie,
+ Map responseHeaders,
+ ChannelHandler... additionalHandlers) {
+ ChannelHandler[] handlers = new ChannelHandler[additionalHandlers.length + 1];
+ handlers[0] =
+ new JobManagerWebAuthenticationHandler(
+ authenticator, signer, "/", secureCookie, responseHeaders);
+ System.arraycopy(additionalHandlers, 0, handlers, 1, additionalHandlers.length);
+ return new EmbeddedChannel(handlers);
+ }
+
+ private static EmbeddedChannel preFileUploadChannel(
+ SpnegoAuthenticator authenticator,
+ JobManagerAuthenticationTokenSigner signer,
+ boolean secureCookie,
+ Map responseHeaders,
+ ChannelHandler... additionalHandlers) {
+ JobManagerWebAuthenticationHandler.Factory factory =
+ new JobManagerWebAuthenticationHandler.Factory() {
+ @Override
+ public ChannelHandler createHandler() {
+ return new JobManagerWebAuthenticationHandler(
+ authenticator, signer, "/", secureCookie, responseHeaders);
+ }
+
+ @Override
+ public ChannelHandler createPreFileUploadHandler() {
+ return JobManagerWebAuthenticationHandler.createPreFileUploadHandler(
+ authenticator, signer, "/", secureCookie, responseHeaders);
+ }
+ };
+ ChannelHandler[] handlers = new ChannelHandler[additionalHandlers.length + 1];
+ handlers[0] = factory.createPreFileUploadHandler();
+ System.arraycopy(additionalHandlers, 0, handlers, 1, additionalHandlers.length);
+ return new EmbeddedChannel(handlers);
+ }
+
+ private static ChannelInboundHandlerAdapter forwardingRecorder(
+ AtomicReference forwarded) {
+ return new ChannelInboundHandlerAdapter() {
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) {
+ forwarded.compareAndSet(null, msg);
+ }
+ };
+ }
+
+ private static JobManagerAuthenticationTokenSigner signer(Clock clock, Duration tokenValidity) {
+ return new JobManagerAuthenticationTokenSigner(SECRET, tokenValidity, clock);
+ }
+
+ private static FullHttpRequest request() {
+ return new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/jobs/overview");
+ }
+
+ private static HttpRequest multipartUploadRequest() {
+ HttpRequest request =
+ new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.POST, "/jars/upload");
+ request.headers()
+ .set(HttpHeaderNames.CONTENT_TYPE, "multipart/form-data; boundary=boundary");
+ return request;
+ }
+}
diff --git a/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebSpnegoITCase.java b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebSpnegoITCase.java
new file mode 100644
index 0000000000000..2c57476bbd546
--- /dev/null
+++ b/flink-runtime/src/test/java/org/apache/flink/runtime/webmonitor/security/JobManagerWebSpnegoITCase.java
@@ -0,0 +1,405 @@
+/*
+ * 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.flink.runtime.webmonitor.security;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.RestOptions;
+import org.apache.flink.configuration.WebOptions;
+import org.apache.flink.configuration.WebOptions.WebAuthenticationType;
+import org.apache.flink.runtime.rest.FileUploadHandler;
+import org.apache.flink.runtime.rest.HttpMethodWrapper;
+import org.apache.flink.runtime.rest.messages.EmptyMessageParameters;
+import org.apache.flink.runtime.rest.messages.EmptyRequestBody;
+import org.apache.flink.runtime.rest.messages.EmptyResponseBody;
+import org.apache.flink.runtime.rest.messages.RuntimeMessageHeaders;
+import org.apache.flink.runtime.rest.util.TestMessageHeaders;
+import org.apache.flink.runtime.rest.util.TestRestHandler;
+import org.apache.flink.runtime.rest.util.TestRestServerEndpoint;
+import org.apache.flink.runtime.security.KerberosUtils;
+import org.apache.flink.runtime.webmonitor.RestfulGateway;
+import org.apache.flink.runtime.webmonitor.TestingRestfulGateway;
+import org.apache.flink.runtime.webmonitor.retriever.GatewayRetriever;
+
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponseStatus;
+
+import okhttp3.MediaType;
+import okhttp3.MultipartBody;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.apache.hadoop.minikdc.MiniKdc;
+import org.apache.hadoop.security.authentication.client.AuthenticatedURL;
+import org.apache.hadoop.security.authentication.util.KerberosUtil;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.security.auth.Subject;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.LoginContext;
+
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.PrivilegedExceptionAction;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Properties;
+import java.util.concurrent.CompletableFuture;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Integration tests for JobManager web SPNEGO authentication. */
+class JobManagerWebSpnegoITCase {
+
+ private static final MediaType JSON = MediaType.parse("application/json");
+ private static final String HOST = "localhost";
+ private static final String CLIENT_PRINCIPAL_NAME = "alice";
+ private static final String HTTP_SERVICE_PRINCIPAL_NAME = "HTTP/" + HOST;
+
+ @Test
+ void shouldRequireSpnegoAndAllowKerberosClient(@TempDir Path tempDir) throws Exception {
+ Properties previousProperties = rememberKerberosProperties();
+ MiniKdc kdc = null;
+ LoginContext loginContext = null;
+ try {
+ Path kdcDir = tempDir.resolve("kdc");
+ Files.createDirectories(kdcDir);
+ Properties kdcConf = MiniKdc.createConf();
+ kdcConf.setProperty(MiniKdc.KDC_BIND_ADDRESS, HOST);
+ kdc = new MiniKdc(kdcConf, kdcDir.toFile());
+ kdc.start();
+ System.setProperty(MiniKdc.JAVA_SECURITY_KRB5_CONF, kdc.getKrb5conf().toString());
+ System.setProperty("sun.security.krb5.disableReferrals", "true");
+
+ Path serverKeytab = tempDir.resolve("server.keytab");
+ Path clientKeytab = tempDir.resolve("client.keytab");
+ kdc.createPrincipal(serverKeytab.toFile(), HTTP_SERVICE_PRINCIPAL_NAME);
+ kdc.createPrincipal(clientKeytab.toFile(), CLIENT_PRINCIPAL_NAME);
+
+ Configuration configuration = createKerberosConfiguration(tempDir, serverKeytab);
+ JobManagerWebAuthenticationHandler.Factory authenticationHandlerFactory =
+ JobManagerWebAuthenticationHandler.createFactory(
+ configuration, Collections.emptyMap())
+ .orElseThrow();
+
+ GatewayRetriever gatewayRetriever =
+ () ->
+ CompletableFuture.completedFuture(
+ new TestingRestfulGateway.Builder().build());
+ TestMessageHeaders
+ jobsOverviewHeaders =
+ TestMessageHeaders.emptyBuilder()
+ .setHttpMethod(HttpMethodWrapper.GET)
+ .setTargetRestEndpointURL("/jobs/overview")
+ .build();
+ TestRestHandler<
+ RestfulGateway,
+ EmptyRequestBody,
+ EmptyResponseBody,
+ EmptyMessageParameters>
+ jobsOverviewHandler =
+ new TestRestHandler<>(
+ gatewayRetriever,
+ jobsOverviewHeaders,
+ CompletableFuture.completedFuture(
+ EmptyResponseBody.getInstance()));
+ TestUploadHeaders uploadHeaders = TestUploadHeaders.INSTANCE;
+ TestRestHandler<
+ RestfulGateway,
+ EmptyRequestBody,
+ EmptyResponseBody,
+ EmptyMessageParameters>
+ uploadHandler =
+ new TestRestHandler<>(
+ gatewayRetriever,
+ uploadHeaders,
+ CompletableFuture.completedFuture(
+ EmptyResponseBody.getInstance()));
+
+ try (TestRestServerEndpoint endpoint =
+ TestRestServerEndpoint.builder(configuration)
+ .withEndpointSpecificPreFileUploadChannelHandlerFactory(
+ authenticationHandlerFactory::createPreFileUploadHandler)
+ .withEndpointSpecificChannelHandlerFactory(
+ authenticationHandlerFactory::createHandler)
+ .withHandler(jobsOverviewHeaders, jobsOverviewHandler)
+ .withHandler(uploadHeaders, uploadHandler)
+ .build()) {
+ endpoint.start();
+ String baseUrl = getBaseUrl(endpoint);
+ Path uploadDir = tempDir.resolve("uploads");
+
+ OkHttpClient httpClient = new OkHttpClient();
+ assertSpnegoChallenge(
+ httpClient, new Request.Builder().url(baseUrl + "/jobs/overview"));
+ assertSpnegoChallenge(
+ httpClient,
+ new Request.Builder()
+ .url(baseUrl + "/jars/upload")
+ .post(RequestBody.create(JSON, "{}")));
+ assertUnauthenticatedMultipartUploadRejectedBeforeUpload(
+ httpClient, baseUrl, uploadDir);
+
+ loginContext =
+ loginFromKeytab(CLIENT_PRINCIPAL_NAME + "@" + kdc.getRealm(), clientKeytab);
+ try (Response jobsOverviewResponse =
+ spnegoRequest(
+ httpClient,
+ new Request.Builder().url(baseUrl + "/jobs/overview"),
+ loginContext.getSubject())) {
+ assertThat(jobsOverviewResponse.code()).isEqualTo(200);
+ assertThat(jobsOverviewResponse.header("Set-Cookie"))
+ .contains(AuthenticatedURL.AUTH_COOKIE + "=");
+ }
+ try (Response uploadResponse =
+ spnegoRequest(
+ httpClient,
+ new Request.Builder()
+ .url(baseUrl + "/jars/upload")
+ .post(multipartUploadBody()),
+ loginContext.getSubject())) {
+ assertThat(uploadResponse.code()).isEqualTo(200);
+ assertThat(uploadResponse.header("Set-Cookie"))
+ .contains(AuthenticatedURL.AUTH_COOKIE + "=");
+ }
+ }
+ } finally {
+ if (loginContext != null) {
+ loginContext.logout();
+ }
+ if (kdc != null) {
+ kdc.stop();
+ }
+ restoreKerberosProperties(previousProperties);
+ }
+ }
+
+ private static void assertUnauthenticatedMultipartUploadRejectedBeforeUpload(
+ OkHttpClient httpClient, String baseUrl, Path uploadDir) throws Exception {
+ assertSpnegoChallenge(
+ httpClient,
+ new Request.Builder().url(baseUrl + "/jars/upload").post(multipartUploadBody()));
+ Path flinkUploadDir = uploadDir.resolve("flink-web-upload");
+ if (Files.exists(flinkUploadDir)) {
+ try (Stream children = Files.list(flinkUploadDir)) {
+ assertThat(children.collect(Collectors.toList())).isEmpty();
+ }
+ }
+ }
+
+ private static RequestBody multipartUploadBody() {
+ return new MultipartBody.Builder()
+ .setType(MultipartBody.FORM)
+ .addFormDataPart(
+ "jarfile",
+ "test-job.jar",
+ RequestBody.create(
+ MediaType.parse("application/java-archive"),
+ "jar-content".getBytes(StandardCharsets.UTF_8)))
+ .addFormDataPart(FileUploadHandler.HTTP_ATTRIBUTE_REQUEST, "{}")
+ .build();
+ }
+
+ private static Configuration createKerberosConfiguration(Path tempDir, Path serverKeytab) {
+ Configuration configuration = new Configuration();
+ configuration.set(RestOptions.BIND_PORT, "0");
+ configuration.set(RestOptions.BIND_ADDRESS, HOST);
+ configuration.set(RestOptions.ADDRESS, HOST);
+ configuration.set(WebOptions.UPLOAD_DIR, tempDir.resolve("uploads").toString());
+ configuration.set(WebOptions.AUTHENTICATION_TYPE, WebAuthenticationType.KERBEROS);
+ configuration.set(WebOptions.AUTHENTICATION_KERBEROS_PRINCIPAL, "*");
+ configuration.set(WebOptions.AUTHENTICATION_KERBEROS_KEYTAB, serverKeytab.toString());
+ configuration.set(WebOptions.AUTHENTICATION_SIGNATURE_SECRET, "shared-test-secret");
+ return configuration;
+ }
+
+ private static void assertSpnegoChallenge(OkHttpClient httpClient, Request.Builder builder)
+ throws Exception {
+ try (Response response = httpClient.newCall(builder.build()).execute()) {
+ assertThat(response.code()).isEqualTo(401);
+ assertThat(response.header("WWW-Authenticate")).isEqualTo("Negotiate");
+ }
+ }
+
+ private static Response spnegoRequest(
+ OkHttpClient httpClient, Request.Builder builder, Subject clientSubject)
+ throws Exception {
+ return Subject.doAs(
+ clientSubject,
+ (PrivilegedExceptionAction)
+ () -> {
+ GSSManager gssManager = GSSManager.getInstance();
+ GSSName serverName =
+ gssManager.createName(
+ "HTTP@" + HOST, GSSName.NT_HOSTBASED_SERVICE);
+ GSSContext gssContext =
+ gssManager.createContext(
+ serverName,
+ KerberosUtil.GSS_SPNEGO_MECH_OID,
+ null,
+ GSSContext.DEFAULT_LIFETIME);
+ try {
+ gssContext.requestMutualAuth(true);
+ byte[] clientToken = new byte[0];
+ for (int i = 0; i < 2; i++) {
+ byte[] outputToken =
+ gssContext.initSecContext(
+ clientToken, 0, clientToken.length);
+ Request request =
+ builder.header(
+ "Authorization",
+ "Negotiate "
+ + Base64.getEncoder()
+ .encodeToString(
+ outputToken))
+ .build();
+ Response response = httpClient.newCall(request).execute();
+ if (response.code() != 401) {
+ return response;
+ }
+ String authenticateHeader =
+ response.header("WWW-Authenticate", "Negotiate");
+ response.close();
+ clientToken = decodeServerToken(authenticateHeader);
+ }
+ throw new IllegalStateException(
+ "SPNEGO authentication did not complete.");
+ } finally {
+ gssContext.dispose();
+ }
+ });
+ }
+
+ private static byte[] decodeServerToken(String authenticateHeader) {
+ String prefix = "Negotiate ";
+ if (!authenticateHeader.startsWith(prefix)) {
+ return new byte[0];
+ }
+ return Base64.getDecoder().decode(authenticateHeader.substring(prefix.length()));
+ }
+
+ private static LoginContext loginFromKeytab(String principal, Path keytab) throws Exception {
+ LoginContext loginContext =
+ new LoginContext(
+ "client",
+ null,
+ null,
+ new javax.security.auth.login.Configuration() {
+ @Override
+ public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
+ return new AppConfigurationEntry[] {
+ KerberosUtils.keytabEntry(keytab.toString(), principal)
+ };
+ }
+ });
+ loginContext.login();
+ return loginContext;
+ }
+
+ private static String getBaseUrl(TestRestServerEndpoint endpoint) {
+ InetSocketAddress serverAddress = endpoint.getServerAddress();
+ return "http://" + serverAddress.getHostString() + ":" + serverAddress.getPort();
+ }
+
+ private static Properties rememberKerberosProperties() {
+ Properties properties = new Properties();
+ remember(properties, MiniKdc.JAVA_SECURITY_KRB5_CONF);
+ remember(properties, "sun.security.krb5.disableReferrals");
+ return properties;
+ }
+
+ private static void remember(Properties properties, String key) {
+ String value = System.getProperty(key);
+ if (value != null) {
+ properties.setProperty(key, value);
+ }
+ }
+
+ private static void restoreKerberosProperties(Properties properties) {
+ restore(properties, MiniKdc.JAVA_SECURITY_KRB5_CONF);
+ restore(properties, "sun.security.krb5.disableReferrals");
+ }
+
+ private static void restore(Properties properties, String key) {
+ if (properties.containsKey(key)) {
+ System.setProperty(key, properties.getProperty(key));
+ } else {
+ System.clearProperty(key);
+ }
+ }
+
+ private static final class TestUploadHeaders
+ implements RuntimeMessageHeaders<
+ EmptyRequestBody, EmptyResponseBody, EmptyMessageParameters> {
+
+ private static final TestUploadHeaders INSTANCE = new TestUploadHeaders();
+
+ @Override
+ public Class getRequestClass() {
+ return EmptyRequestBody.class;
+ }
+
+ @Override
+ public Class getResponseClass() {
+ return EmptyResponseBody.class;
+ }
+
+ @Override
+ public HttpResponseStatus getResponseStatusCode() {
+ return HttpResponseStatus.OK;
+ }
+
+ @Override
+ public EmptyMessageParameters getUnresolvedMessageParameters() {
+ return EmptyMessageParameters.getInstance();
+ }
+
+ @Override
+ public HttpMethodWrapper getHttpMethod() {
+ return HttpMethodWrapper.POST;
+ }
+
+ @Override
+ public String getTargetRestEndpointURL() {
+ return "/jars/upload";
+ }
+
+ @Override
+ public String getDescription() {
+ return "Test upload endpoint.";
+ }
+
+ @Override
+ public String operationId() {
+ return "testUpload";
+ }
+
+ @Override
+ public boolean acceptsFileUploads() {
+ return true;
+ }
+ }
+}
diff --git a/flink-table/flink-sql-gateway/pom.xml b/flink-table/flink-sql-gateway/pom.xml
index c9e035f5a9a83..7bc3098f4e8b6 100644
--- a/flink-table/flink-sql-gateway/pom.xml
+++ b/flink-table/flink-sql-gateway/pom.xml
@@ -60,7 +60,7 @@
org.apache.flink
flink-sql-gateway-api
${project.version}
- ${flink.markBundledAsOptional}
+ ${flink.markBundledAsOptional}
org.apache.flink
@@ -75,6 +75,54 @@
${flink.markBundledAsOptional}
+
+ org.apache.hadoop
+ hadoop-auth
+ ${flink.hadoop.version}
+ ${flink.markBundledAsOptional}
+
+
+ log4j
+ log4j
+
+
+ org.slf4j
+ slf4j-log4j12
+
+
+ ch.qos.reload4j
+ reload4j
+
+
+ org.slf4j
+ slf4j-reload4j
+
+
+ io.dropwizard.metrics
+ metrics-core
+
+
+ org.apache.zookeeper
+ zookeeper
+
+
+ org.apache.curator
+ curator-framework
+
+
+ io.netty
+ netty-handler
+
+
+
+
+ org.apache.hadoop
+ hadoop-annotations
+ ${flink.hadoop.version}
+ ${flink.markBundledAsOptional}
+ provided
+
+
org.apache.flink
@@ -118,6 +166,26 @@
com.squareup.okhttp3
okhttp
test
+
+
+ org.apache.hadoop
+ hadoop-minikdc
+ ${minikdc.version}
+ test
+
+
+ log4j
+ log4j
+
+
+ org.slf4j
+ slf4j-log4j12
+
+
+ org.slf4j
+ slf4j-reload4j
+
+
org.apache.flink
@@ -165,6 +233,18 @@
org.apache.flink:flink-sql-gateway-api
org.quartz-scheduler:quartz
+ org.apache.hadoop:hadoop-auth
+ commons-codec:commons-codec
+ org.apache.httpcomponents:httpclient
+ org.apache.httpcomponents:httpcore
+ org.apache.hadoop.thirdparty:hadoop-shaded-guava
+ org.apache.kerby:kerb-core
+ org.apache.kerby:kerby-pkix
+ org.apache.kerby:kerby-asn1
+ org.apache.kerby:kerby-util
+ org.apache.kerby:kerb-util
+ org.apache.kerby:kerby-config
+ org.apache.kerby:kerb-crypto
@@ -184,6 +264,10 @@
org.terracotta.quartz
org.apache.flink.table.shaded.org.terracotta.quartz
+
+ org.apache.hadoop
+ org.apache.flink.table.shaded.org.apache.hadoop
+
diff --git a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/SqlGatewayRestEndpointFactory.java b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/SqlGatewayRestEndpointFactory.java
index 697b10196bafd..25d2b1038c35b 100644
--- a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/SqlGatewayRestEndpointFactory.java
+++ b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/SqlGatewayRestEndpointFactory.java
@@ -25,12 +25,21 @@
import org.apache.flink.table.gateway.api.endpoint.SqlGatewayEndpointFactory;
import org.apache.flink.table.gateway.api.endpoint.SqlGatewayEndpointFactoryUtils;
import org.apache.flink.table.gateway.api.utils.SqlGatewayException;
+import org.apache.flink.table.gateway.rest.security.SqlGatewaySpnegoAuthenticationHandlerFactory;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.ADDRESS;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.AUTHENTICATION_COOKIE_PATH;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_KEYTAB;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_NAME_RULES;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_PRINCIPAL;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET_FILE;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.AUTHENTICATION_TOKEN_VALIDITY;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.AUTHENTICATION_TYPE;
import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.BIND_ADDRESS;
import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.BIND_PORT;
import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.PORT;
@@ -51,6 +60,7 @@ public SqlGatewayEndpoint createSqlGatewayEndpoint(Context context) {
rebuildRestEndpointOptions(
context.getEndpointOptions(), context.getFlinkConfiguration().toMap());
try {
+ SqlGatewaySpnegoAuthenticationHandlerFactory.validateConfiguration(config);
return new SqlGatewayRestEndpoint(config, context.getSqlGatewayService());
} catch (Exception e) {
throw new SqlGatewayException("Cannot start the rest endpoint.", e);
@@ -96,6 +106,14 @@ public Set> optionalOptions() {
options.add(BIND_ADDRESS);
options.add(PORT);
options.add(BIND_PORT);
+ options.add(AUTHENTICATION_TYPE);
+ options.add(AUTHENTICATION_KERBEROS_PRINCIPAL);
+ options.add(AUTHENTICATION_KERBEROS_KEYTAB);
+ options.add(AUTHENTICATION_KERBEROS_NAME_RULES);
+ options.add(AUTHENTICATION_TOKEN_VALIDITY);
+ options.add(AUTHENTICATION_COOKIE_PATH);
+ options.add(AUTHENTICATION_SIGNATURE_SECRET);
+ options.add(AUTHENTICATION_SIGNATURE_SECRET_FILE);
return options;
}
}
diff --git a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SpnegoAuthenticator.java b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SpnegoAuthenticator.java
new file mode 100644
index 0000000000000..ccf98c5d62483
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SpnegoAuthenticator.java
@@ -0,0 +1,28 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+
+/** Authentication step contract used by the Netty SPNEGO handler. */
+interface SpnegoAuthenticator {
+
+ SqlGatewaySpnegoAuthenticationResult authenticate(String authorizationHeader)
+ throws AuthenticationException;
+}
diff --git a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewayAuthenticationTokenSigner.java b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewayAuthenticationTokenSigner.java
new file mode 100644
index 0000000000000..8746972935706
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewayAuthenticationTokenSigner.java
@@ -0,0 +1,112 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+import java.nio.charset.StandardCharsets;
+import java.security.InvalidKeyException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.Optional;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/** Signs and verifies Hadoop-compatible SQL Gateway REST authentication tokens. */
+final class SqlGatewayAuthenticationTokenSigner {
+
+ static final String TOKEN_TYPE_KERBEROS = "kerberos";
+
+ private static final String SIGNATURE_SEPARATOR = "&s=";
+ private static final String SIGNING_ALGORITHM = "HmacSHA256";
+
+ private final byte[] secret;
+ private final Duration tokenValidity;
+ private final Clock clock;
+
+ SqlGatewayAuthenticationTokenSigner(byte[] secret, Duration tokenValidity, Clock clock) {
+ this.secret = checkNotNull(secret).clone();
+ this.tokenValidity = checkNotNull(tokenValidity);
+ this.clock = checkNotNull(clock);
+ }
+
+ String signToken(String userName, String principal) {
+ AuthenticationToken token =
+ new AuthenticationToken(userName, principal, TOKEN_TYPE_KERBEROS);
+ token.setExpires(clock.millis() + tokenValidity.toMillis());
+ return sign(token.toString());
+ }
+
+ Optional verifyToken(String signedToken) {
+ if (signedToken == null || signedToken.isEmpty()) {
+ return Optional.empty();
+ }
+ try {
+ AuthenticationToken token = AuthenticationToken.parse(verifyAndExtract(signedToken));
+ if (!TOKEN_TYPE_KERBEROS.equals(token.getType())) {
+ return Optional.empty();
+ }
+ if (token.getExpires() != -1 && clock.millis() > token.getExpires()) {
+ return Optional.empty();
+ }
+ return Optional.of(token);
+ } catch (AuthenticationException | IllegalArgumentException e) {
+ return Optional.empty();
+ }
+ }
+
+ private String sign(String rawToken) {
+ return rawToken + SIGNATURE_SEPARATOR + computeSignature(rawToken);
+ }
+
+ private String verifyAndExtract(String signedToken) {
+ int index = signedToken.lastIndexOf(SIGNATURE_SEPARATOR);
+ if (index < 0) {
+ throw new IllegalArgumentException("Authentication token is not signed.");
+ }
+
+ String rawToken = signedToken.substring(0, index);
+ String expectedSignature = computeSignature(rawToken);
+ String actualSignature = signedToken.substring(index + SIGNATURE_SEPARATOR.length());
+ if (!MessageDigest.isEqual(
+ expectedSignature.getBytes(StandardCharsets.UTF_8),
+ actualSignature.getBytes(StandardCharsets.UTF_8))) {
+ throw new IllegalArgumentException("Authentication token signature is invalid.");
+ }
+ return rawToken;
+ }
+
+ private String computeSignature(String value) {
+ try {
+ Mac mac = Mac.getInstance(SIGNING_ALGORITHM);
+ mac.init(new SecretKeySpec(secret, SIGNING_ALGORITHM));
+ return Base64.getEncoder()
+ .encodeToString(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException | InvalidKeyException e) {
+ throw new IllegalStateException("Could not sign SQL Gateway authentication token.", e);
+ }
+ }
+}
diff --git a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewayRestAuthenticationConfig.java b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewayRestAuthenticationConfig.java
new file mode 100644
index 0000000000000..0e3fb4ae78c10
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewayRestAuthenticationConfig.java
@@ -0,0 +1,245 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.DelegatingConfiguration;
+import org.apache.flink.configuration.SecurityOptions;
+import org.apache.flink.table.gateway.api.endpoint.SqlGatewayEndpointFactoryUtils;
+import org.apache.flink.table.gateway.rest.SqlGatewayRestEndpointFactory;
+import org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions;
+import org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.SqlGatewayRestAuthenticationType;
+import org.apache.flink.util.ConfigurationException;
+import org.apache.flink.util.StringUtils;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.SecureRandom;
+import java.time.Duration;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/** Parsed SQL Gateway REST authentication configuration. */
+final class SqlGatewayRestAuthenticationConfig {
+
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+ private final SqlGatewayRestAuthenticationType type;
+ private final String principal;
+ private final String keytab;
+ private final String nameRules;
+ private final Duration tokenValidity;
+ private final String cookiePath;
+ private final byte[] signatureSecret;
+ private final boolean secureCookie;
+ private final String restAddress;
+
+ private SqlGatewayRestAuthenticationConfig(
+ SqlGatewayRestAuthenticationType type,
+ String principal,
+ String keytab,
+ String nameRules,
+ Duration tokenValidity,
+ String cookiePath,
+ byte[] signatureSecret,
+ boolean secureCookie,
+ String restAddress) {
+ this.type = checkNotNull(type);
+ this.principal = principal;
+ this.keytab = keytab;
+ this.nameRules = checkNotNull(nameRules);
+ this.tokenValidity = checkNotNull(tokenValidity);
+ this.cookiePath = checkNotNull(cookiePath);
+ this.signatureSecret = signatureSecret.clone();
+ this.secureCookie = secureCookie;
+ this.restAddress = checkNotNull(restAddress);
+ }
+
+ static SqlGatewayRestAuthenticationConfig fromConfiguration(Configuration configuration)
+ throws ConfigurationException {
+ Configuration endpointConfig =
+ new DelegatingConfiguration(
+ configuration,
+ SqlGatewayEndpointFactoryUtils.getSqlGatewayOptionPrefix(
+ SqlGatewayRestEndpointFactory.IDENTIFIER));
+
+ SqlGatewayRestAuthenticationType type =
+ endpointConfig.get(SqlGatewayRestOptions.AUTHENTICATION_TYPE);
+ boolean secureCookie = SecurityOptions.isRestSSLEnabled(configuration);
+ String restAddress = configuration.get(org.apache.flink.configuration.RestOptions.ADDRESS);
+ if (type == SqlGatewayRestAuthenticationType.NONE) {
+ return new SqlGatewayRestAuthenticationConfig(
+ type,
+ null,
+ null,
+ SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_NAME_RULES.defaultValue(),
+ SqlGatewayRestOptions.AUTHENTICATION_TOKEN_VALIDITY.defaultValue(),
+ SqlGatewayRestOptions.AUTHENTICATION_COOKIE_PATH.defaultValue(),
+ new byte[0],
+ secureCookie,
+ restAddress);
+ }
+
+ String principal =
+ endpointConfig.get(SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_PRINCIPAL);
+ String keytab = endpointConfig.get(SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_KEYTAB);
+ String nameRules =
+ endpointConfig.get(SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_NAME_RULES);
+ Duration tokenValidity =
+ endpointConfig.get(SqlGatewayRestOptions.AUTHENTICATION_TOKEN_VALIDITY);
+ String cookiePath = endpointConfig.get(SqlGatewayRestOptions.AUTHENTICATION_COOKIE_PATH);
+ String signatureSecret =
+ endpointConfig.get(SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET);
+ String signatureSecretFile =
+ endpointConfig.get(SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET_FILE);
+
+ if (!tokenValidity.isPositive()) {
+ throw new ConfigurationException(
+ String.format(
+ "SQL Gateway REST authentication option '%s' must be positive.",
+ fullKey(SqlGatewayRestOptions.AUTHENTICATION_TOKEN_VALIDITY.key())));
+ }
+ if (StringUtils.isNullOrWhitespaceOnly(cookiePath) || !cookiePath.startsWith("/")) {
+ throw new ConfigurationException(
+ String.format(
+ "SQL Gateway REST authentication option '%s' must start with '/'.",
+ fullKey(SqlGatewayRestOptions.AUTHENTICATION_COOKIE_PATH.key())));
+ }
+
+ byte[] secret = resolveSignatureSecret(signatureSecret, signatureSecretFile);
+ requireNonEmpty(principal, SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_PRINCIPAL.key());
+ requireNonEmpty(keytab, SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_KEYTAB.key());
+
+ return new SqlGatewayRestAuthenticationConfig(
+ type,
+ principal,
+ keytab,
+ nameRules,
+ tokenValidity,
+ cookiePath,
+ secret,
+ secureCookie,
+ restAddress);
+ }
+
+ private static byte[] resolveSignatureSecret(String secret, String secretFile)
+ throws ConfigurationException {
+ boolean hasSecret = !StringUtils.isNullOrWhitespaceOnly(secret);
+ boolean hasSecretFile = !StringUtils.isNullOrWhitespaceOnly(secretFile);
+ if (hasSecret && hasSecretFile) {
+ throw new ConfigurationException(
+ String.format(
+ "Only one of SQL Gateway REST authentication options '%s' and '%s' can be configured.",
+ fullKey(SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET.key()),
+ fullKey(
+ SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET_FILE
+ .key())));
+ }
+ if (hasSecret) {
+ return secret.getBytes(StandardCharsets.UTF_8);
+ }
+ if (hasSecretFile) {
+ try {
+ String fileSecret =
+ Files.readString(Path.of(secretFile), StandardCharsets.UTF_8).trim();
+ if (fileSecret.isEmpty()) {
+ throw new ConfigurationException(
+ String.format(
+ "SQL Gateway REST authentication option '%s' points to an empty file.",
+ fullKey(
+ SqlGatewayRestOptions
+ .AUTHENTICATION_SIGNATURE_SECRET_FILE
+ .key())));
+ }
+ return fileSecret.getBytes(StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new ConfigurationException(
+ String.format(
+ "Could not read SQL Gateway REST authentication secret file configured by '%s'.",
+ fullKey(
+ SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET_FILE
+ .key())),
+ e);
+ }
+ }
+
+ byte[] randomSecret = new byte[32];
+ SECURE_RANDOM.nextBytes(randomSecret);
+ return randomSecret;
+ }
+
+ private static void requireNonEmpty(String value, String localKey)
+ throws ConfigurationException {
+ if (StringUtils.isNullOrWhitespaceOnly(value)) {
+ throw new ConfigurationException(
+ String.format(
+ "SQL Gateway REST authentication option '%s' is required when '%s' is KERBEROS.",
+ fullKey(localKey),
+ fullKey(SqlGatewayRestOptions.AUTHENTICATION_TYPE.key())));
+ }
+ }
+
+ private static String fullKey(String localKey) {
+ return SqlGatewayEndpointFactoryUtils.getSqlGatewayOptionPrefix(
+ SqlGatewayRestEndpointFactory.IDENTIFIER)
+ + localKey;
+ }
+
+ SqlGatewayRestAuthenticationType type() {
+ return type;
+ }
+
+ boolean isKerberosEnabled() {
+ return type == SqlGatewayRestAuthenticationType.KERBEROS;
+ }
+
+ String principal() {
+ return principal;
+ }
+
+ String keytab() {
+ return keytab;
+ }
+
+ String nameRules() {
+ return nameRules;
+ }
+
+ Duration tokenValidity() {
+ return tokenValidity;
+ }
+
+ String cookiePath() {
+ return cookiePath;
+ }
+
+ byte[] signatureSecret() {
+ return signatureSecret.clone();
+ }
+
+ boolean secureCookie() {
+ return secureCookie;
+ }
+
+ String restAddress() {
+ return restAddress;
+ }
+}
diff --git a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationHandler.java b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationHandler.java
new file mode 100644
index 0000000000000..cd0be41a0d1b4
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationHandler.java
@@ -0,0 +1,206 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.flink.shaded.netty4.io.netty.buffer.Unpooled;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelDuplexHandler;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandlerContext;
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelPromise;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.DefaultFullHttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.FullHttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.FullHttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaderNames;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaderValues;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaders;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpVersion;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.cookie.Cookie;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.cookie.ServerCookieDecoder;
+import org.apache.flink.shaded.netty4.io.netty.util.AttributeKey;
+import org.apache.flink.shaded.netty4.io.netty.util.ReferenceCountUtil;
+
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/** Netty handler that enforces SQL Gateway REST SPNEGO authentication. */
+final class SqlGatewaySpnegoAuthenticationHandler extends ChannelDuplexHandler {
+
+ static final String HADOOP_AUTH_COOKIE = "hadoop.auth";
+
+ private static final Logger LOG =
+ LoggerFactory.getLogger(SqlGatewaySpnegoAuthenticationHandler.class);
+ private static final AttributeKey RESPONSE_AUTHENTICATION_ATTRIBUTE =
+ AttributeKey.valueOf("sql-gateway-rest-spnego-response-authentication");
+
+ private final SpnegoAuthenticator authenticator;
+ private final SqlGatewayAuthenticationTokenSigner tokenSigner;
+ private final String cookiePath;
+ private final boolean secureCookie;
+ private final Map responseHeaders;
+
+ SqlGatewaySpnegoAuthenticationHandler(
+ SpnegoAuthenticator authenticator,
+ SqlGatewayAuthenticationTokenSigner tokenSigner,
+ String cookiePath,
+ boolean secureCookie,
+ Map responseHeaders) {
+ this.authenticator = checkNotNull(authenticator);
+ this.tokenSigner = checkNotNull(tokenSigner);
+ this.cookiePath = checkNotNull(cookiePath);
+ this.secureCookie = secureCookie;
+ this.responseHeaders = checkNotNull(responseHeaders);
+ }
+
+ @Override
+ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
+ if (!(msg instanceof FullHttpRequest)) {
+ ctx.fireChannelRead(msg);
+ return;
+ }
+
+ FullHttpRequest request = (FullHttpRequest) msg;
+ Optional cookieToken = getAuthenticationTokenFromCookie(request);
+ if (cookieToken.isPresent()) {
+ ctx.fireChannelRead(msg);
+ return;
+ }
+
+ try {
+ SqlGatewaySpnegoAuthenticationResult result =
+ authenticator.authenticate(
+ request.headers().get(HttpHeaderNames.AUTHORIZATION));
+ if (!result.isAuthenticated()) {
+ sendResponse(
+ ctx, request, HttpResponseStatus.UNAUTHORIZED, result.authenticateHeader());
+ return;
+ }
+
+ AuthenticationToken authenticationToken = result.authenticationToken();
+ String signedToken =
+ tokenSigner.signToken(
+ authenticationToken.getUserName(), authenticationToken.getName());
+ ctx.channel()
+ .attr(RESPONSE_AUTHENTICATION_ATTRIBUTE)
+ .set(new ResponseAuthentication(signedToken, result.authenticateHeader()));
+ ctx.fireChannelRead(msg);
+ } catch (AuthenticationException e) {
+ LOG.debug("SQL Gateway REST SPNEGO authentication failed.", e);
+ sendResponse(ctx, request, HttpResponseStatus.FORBIDDEN, null);
+ }
+ }
+
+ @Override
+ public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
+ throws Exception {
+ if (msg instanceof HttpResponse) {
+ ResponseAuthentication responseAuthentication =
+ ctx.channel().attr(RESPONSE_AUTHENTICATION_ATTRIBUTE).getAndSet(null);
+ if (responseAuthentication != null) {
+ HttpHeaders headers = ((HttpResponse) msg).headers();
+ headers.add(HttpHeaderNames.SET_COOKIE, createAuthCookie(responseAuthentication));
+ if (responseAuthentication.authenticateHeader() != null) {
+ headers.set(
+ HttpHeaderNames.WWW_AUTHENTICATE,
+ responseAuthentication.authenticateHeader());
+ }
+ }
+ }
+ ctx.write(msg, promise);
+ }
+
+ private Optional getAuthenticationTokenFromCookie(
+ FullHttpRequest request) {
+ List cookieHeaders = request.headers().getAll(HttpHeaderNames.COOKIE);
+ for (String cookieHeader : cookieHeaders) {
+ Set cookies = ServerCookieDecoder.STRICT.decode(cookieHeader);
+ for (Cookie cookie : cookies) {
+ if (HADOOP_AUTH_COOKIE.equals(cookie.name())) {
+ return tokenSigner.verifyToken(cookie.value());
+ }
+ }
+ }
+ return Optional.empty();
+ }
+
+ private void sendResponse(
+ ChannelHandlerContext ctx,
+ FullHttpRequest request,
+ HttpResponseStatus status,
+ @Nullable String authenticateHeader) {
+ FullHttpResponse response =
+ new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, status, Unpooled.EMPTY_BUFFER);
+ responseHeaders.forEach((name, value) -> response.headers().set(name, value));
+ response.headers().set(HttpHeaderNames.CONTENT_LENGTH, 0);
+ if (authenticateHeader != null) {
+ response.headers().set(HttpHeaderNames.WWW_AUTHENTICATE, authenticateHeader);
+ }
+ if (!HttpHeaderValues.CLOSE.contentEqualsIgnoreCase(
+ request.headers().get(HttpHeaderNames.CONNECTION))) {
+ response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
+ }
+ ReferenceCountUtil.release(request);
+ ctx.writeAndFlush(response);
+ }
+
+ private String createAuthCookie(ResponseAuthentication responseAuthentication) {
+ StringBuilder cookie =
+ new StringBuilder(HADOOP_AUTH_COOKIE)
+ .append("=\"")
+ .append(responseAuthentication.signedToken())
+ .append("\"")
+ .append("; Path=")
+ .append(cookiePath);
+ if (secureCookie) {
+ cookie.append("; Secure");
+ }
+ cookie.append("; HttpOnly");
+ return cookie.toString();
+ }
+
+ private static final class ResponseAuthentication {
+ private final String signedToken;
+ @Nullable private final String authenticateHeader;
+
+ private ResponseAuthentication(String signedToken, @Nullable String authenticateHeader) {
+ this.signedToken = signedToken;
+ this.authenticateHeader = authenticateHeader;
+ }
+
+ private String signedToken() {
+ return signedToken;
+ }
+
+ @Nullable
+ private String authenticateHeader() {
+ return authenticateHeader;
+ }
+ }
+}
diff --git a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationHandlerFactory.java b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationHandlerFactory.java
new file mode 100644
index 0000000000000..00b6f49ecc1ad
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationHandlerFactory.java
@@ -0,0 +1,73 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.runtime.io.network.netty.InboundChannelHandlerFactory;
+import org.apache.flink.util.ConfigurationException;
+
+import org.apache.flink.shaded.netty4.io.netty.channel.ChannelHandler;
+
+import java.time.Clock;
+import java.util.Map;
+import java.util.Optional;
+
+/** Service-loaded Netty handler factory for SQL Gateway REST SPNEGO authentication. */
+public final class SqlGatewaySpnegoAuthenticationHandlerFactory
+ implements InboundChannelHandlerFactory {
+
+ private static final int PRIORITY = 1000;
+
+ @Override
+ public int priority() {
+ return PRIORITY;
+ }
+
+ @Override
+ public Optional createHandler(
+ Configuration configuration, Map responseHeaders)
+ throws ConfigurationException {
+ SqlGatewayRestAuthenticationConfig config =
+ SqlGatewayRestAuthenticationConfig.fromConfiguration(configuration);
+ if (!config.isKerberosEnabled()) {
+ return Optional.empty();
+ }
+
+ SqlGatewaySpnegoAuthenticator authenticator = SqlGatewaySpnegoAuthenticator.create(config);
+ SqlGatewayAuthenticationTokenSigner signer =
+ new SqlGatewayAuthenticationTokenSigner(
+ config.signatureSecret(), config.tokenValidity(), Clock.systemUTC());
+ return Optional.of(
+ new SqlGatewaySpnegoAuthenticationHandler(
+ authenticator,
+ signer,
+ config.cookiePath(),
+ config.secureCookie(),
+ responseHeaders));
+ }
+
+ public static void validateConfiguration(Configuration configuration)
+ throws ConfigurationException {
+ SqlGatewayRestAuthenticationConfig config =
+ SqlGatewayRestAuthenticationConfig.fromConfiguration(configuration);
+ if (config.isKerberosEnabled()) {
+ SqlGatewaySpnegoAuthenticator.create(config);
+ }
+ }
+}
diff --git a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationResult.java b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationResult.java
new file mode 100644
index 0000000000000..6c61fd0b33e3b
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationResult.java
@@ -0,0 +1,61 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+
+import javax.annotation.Nullable;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/** Result of one SPNEGO acceptor step. */
+final class SqlGatewaySpnegoAuthenticationResult {
+
+ @Nullable private final AuthenticationToken authenticationToken;
+ @Nullable private final String authenticateHeader;
+
+ private SqlGatewaySpnegoAuthenticationResult(
+ @Nullable AuthenticationToken authenticationToken,
+ @Nullable String authenticateHeader) {
+ this.authenticationToken = authenticationToken;
+ this.authenticateHeader = authenticateHeader;
+ }
+
+ static SqlGatewaySpnegoAuthenticationResult authenticated(
+ AuthenticationToken authenticationToken, @Nullable String authenticateHeader) {
+ return new SqlGatewaySpnegoAuthenticationResult(authenticationToken, authenticateHeader);
+ }
+
+ static SqlGatewaySpnegoAuthenticationResult challenge(@Nullable String authenticateHeader) {
+ return new SqlGatewaySpnegoAuthenticationResult(null, authenticateHeader);
+ }
+
+ boolean isAuthenticated() {
+ return authenticationToken != null;
+ }
+
+ AuthenticationToken authenticationToken() {
+ return checkNotNull(authenticationToken);
+ }
+
+ @Nullable
+ String authenticateHeader() {
+ return authenticateHeader;
+ }
+}
diff --git a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticator.java b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticator.java
new file mode 100644
index 0000000000000..049beef1d71fb
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticator.java
@@ -0,0 +1,297 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.flink.util.ConfigurationException;
+
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+import org.apache.hadoop.security.authentication.util.KerberosName;
+import org.apache.hadoop.security.authentication.util.KerberosUtil;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSCredential;
+import org.ietf.jgss.GSSException;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.Oid;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.annotation.Nullable;
+import javax.security.auth.Subject;
+import javax.security.auth.kerberos.KerberosKey;
+import javax.security.auth.kerberos.KerberosPrincipal;
+import javax.security.auth.kerberos.KeyTab;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.UnknownHostException;
+import java.security.Principal;
+import java.security.PrivilegedActionException;
+import java.security.PrivilegedExceptionAction;
+import java.util.Base64;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+import static org.apache.flink.util.Preconditions.checkNotNull;
+
+/** Kerberos/SPNEGO acceptor for the SQL Gateway REST endpoint. */
+final class SqlGatewaySpnegoAuthenticator implements SpnegoAuthenticator {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SqlGatewaySpnegoAuthenticator.class);
+
+ static final String AUTHENTICATION_SCHEME_NEGOTIATE = "Negotiate";
+ static final String AUTHENTICATE_HEADER_NEGOTIATE = "Negotiate";
+
+ private static final Pattern HTTP_PRINCIPAL_PATTERN = Pattern.compile("HTTP/.*");
+
+ private final Subject serverSubject;
+ private final GSSManager gssManager;
+
+ private SqlGatewaySpnegoAuthenticator(Subject serverSubject, GSSManager gssManager) {
+ this.serverSubject = checkNotNull(serverSubject);
+ this.gssManager = checkNotNull(gssManager);
+ }
+
+ static SqlGatewaySpnegoAuthenticator create(SqlGatewayRestAuthenticationConfig config)
+ throws ConfigurationException {
+ String principal = replaceHostPattern(config.principal(), config.restAddress());
+ File keytabFile = new File(config.keytab());
+ if (!keytabFile.isFile()) {
+ throw new ConfigurationException(
+ "SQL Gateway REST authentication Kerberos keytab does not exist or is not a file: "
+ + config.keytab());
+ }
+
+ Subject serverSubject = new Subject();
+ KeyTab keytab = KeyTab.getInstance(keytabFile);
+ serverSubject.getPrivateCredentials().add(keytab);
+
+ if ("*".equals(principal)) {
+ addWildcardHttpPrincipals(serverSubject, config.keytab());
+ } else {
+ addPrincipal(serverSubject, keytab, principal, config.keytab());
+ }
+
+ try {
+ KerberosName.setRules(config.nameRules());
+ } catch (IllegalArgumentException e) {
+ throw new ConfigurationException(
+ "Invalid SQL Gateway REST authentication Kerberos name rules.", e);
+ }
+
+ GSSManager gssManager;
+ try {
+ gssManager =
+ Subject.doAs(
+ serverSubject,
+ (PrivilegedExceptionAction) GSSManager::getInstance);
+ } catch (PrivilegedActionException e) {
+ throw new ConfigurationException(
+ "Could not initialize SQL Gateway REST SPNEGO acceptor.", e.getException());
+ }
+
+ return new SqlGatewaySpnegoAuthenticator(serverSubject, gssManager);
+ }
+
+ @Override
+ public SqlGatewaySpnegoAuthenticationResult authenticate(String authorizationHeader)
+ throws AuthenticationException {
+ if (!hasNegotiateScheme(authorizationHeader)) {
+ return SqlGatewaySpnegoAuthenticationResult.challenge(AUTHENTICATE_HEADER_NEGOTIATE);
+ }
+
+ String encodedClientToken =
+ authorizationHeader.substring(AUTHENTICATION_SCHEME_NEGOTIATE.length()).trim();
+ if (encodedClientToken.isEmpty()) {
+ throw new AuthenticationException("Missing SPNEGO token.");
+ }
+
+ byte[] clientToken;
+ try {
+ clientToken = Base64.getDecoder().decode(encodedClientToken);
+ } catch (IllegalArgumentException e) {
+ throw new AuthenticationException("Invalid SPNEGO token encoding.", e);
+ }
+
+ try {
+ String serverPrincipal = KerberosUtil.getTokenServerName(clientToken);
+ if (!serverPrincipal.startsWith("HTTP/")) {
+ throw new AuthenticationException(
+ "Invalid SPNEGO server principal in client token.");
+ }
+ return Subject.doAs(
+ serverSubject,
+ (PrivilegedExceptionAction)
+ () -> runWithPrincipal(serverPrincipal, clientToken));
+ } catch (PrivilegedActionException e) {
+ Throwable cause = e.getException();
+ if (cause instanceof AuthenticationException) {
+ throw (AuthenticationException) cause;
+ }
+ throw new AuthenticationException(cause);
+ } catch (AuthenticationException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new AuthenticationException(e);
+ }
+ }
+
+ private SqlGatewaySpnegoAuthenticationResult runWithPrincipal(
+ String serverPrincipal, byte[] clientToken) throws GSSException, IOException {
+ GSSContext gssContext = null;
+ GSSCredential gssCredential = null;
+ try {
+ LOG.trace(
+ "Starting SQL Gateway REST SPNEGO step for server principal {}.",
+ serverPrincipal);
+ gssCredential =
+ gssManager.createCredential(
+ gssManager.createName(
+ serverPrincipal, KerberosUtil.NT_GSS_KRB5_PRINCIPAL_OID),
+ GSSCredential.INDEFINITE_LIFETIME,
+ new Oid[] {
+ KerberosUtil.GSS_SPNEGO_MECH_OID, KerberosUtil.GSS_KRB5_MECH_OID
+ },
+ GSSCredential.ACCEPT_ONLY);
+ gssContext = gssManager.createContext(gssCredential);
+ byte[] serverToken = gssContext.acceptSecContext(clientToken, 0, clientToken.length);
+ String authenticateHeader = createAuthenticateHeader(serverToken);
+
+ if (!gssContext.isEstablished()) {
+ LOG.trace("SQL Gateway REST SPNEGO step is not complete yet.");
+ return SqlGatewaySpnegoAuthenticationResult.challenge(
+ authenticateHeader == null
+ ? AUTHENTICATE_HEADER_NEGOTIATE
+ : authenticateHeader);
+ }
+
+ String clientPrincipal = gssContext.getSrcName().toString();
+ String userName = new KerberosName(clientPrincipal).getShortName();
+ AuthenticationToken token =
+ new AuthenticationToken(
+ userName,
+ clientPrincipal,
+ SqlGatewayAuthenticationTokenSigner.TOKEN_TYPE_KERBEROS);
+ LOG.trace(
+ "SQL Gateway REST SPNEGO completed for client principal {}.", clientPrincipal);
+ return SqlGatewaySpnegoAuthenticationResult.authenticated(token, authenticateHeader);
+ } finally {
+ if (gssContext != null) {
+ gssContext.dispose();
+ }
+ if (gssCredential != null) {
+ gssCredential.dispose();
+ }
+ }
+ }
+
+ @Nullable
+ private static String createAuthenticateHeader(byte[] serverToken) {
+ if (serverToken == null || serverToken.length == 0) {
+ return null;
+ }
+ return AUTHENTICATE_HEADER_NEGOTIATE
+ + " "
+ + Base64.getEncoder().encodeToString(serverToken);
+ }
+
+ private static boolean hasNegotiateScheme(String authorizationHeader) {
+ return authorizationHeader != null
+ && authorizationHeader.length() >= AUTHENTICATION_SCHEME_NEGOTIATE.length()
+ && authorizationHeader.regionMatches(
+ true,
+ 0,
+ AUTHENTICATION_SCHEME_NEGOTIATE,
+ 0,
+ AUTHENTICATION_SCHEME_NEGOTIATE.length())
+ && (authorizationHeader.length() == AUTHENTICATION_SCHEME_NEGOTIATE.length()
+ || Character.isWhitespace(
+ authorizationHeader.charAt(
+ AUTHENTICATION_SCHEME_NEGOTIATE.length())));
+ }
+
+ static String replaceHostPattern(String principal, String restAddress)
+ throws ConfigurationException {
+ if (!principal.contains("_HOST")) {
+ return principal;
+ }
+ try {
+ String hostName;
+ if (restAddress == null || restAddress.isEmpty() || "0.0.0.0".equals(restAddress)) {
+ hostName = KerberosUtil.getLocalHostName();
+ } else {
+ hostName = InetAddress.getByName(restAddress).getCanonicalHostName();
+ }
+ return principal.replace("_HOST", hostName.toLowerCase(Locale.US));
+ } catch (UnknownHostException e) {
+ throw new ConfigurationException(
+ "Could not resolve _HOST in SQL Gateway REST Kerberos principal.", e);
+ }
+ }
+
+ private static void addWildcardHttpPrincipals(Subject serverSubject, String keytab)
+ throws ConfigurationException {
+ String[] principals;
+ try {
+ principals = KerberosUtil.getPrincipalNames(keytab, HTTP_PRINCIPAL_PATTERN);
+ } catch (IOException e) {
+ throw new ConfigurationException(
+ "Could not read SQL Gateway REST authentication Kerberos keytab.", e);
+ }
+ if (principals.length == 0) {
+ throw new ConfigurationException(
+ "SQL Gateway REST authentication Kerberos keytab does not contain any HTTP principals.");
+ }
+ for (String principal : principals) {
+ addPrincipal(serverSubject, null, principal, keytab);
+ }
+ }
+
+ private static void addPrincipal(
+ Subject serverSubject, KeyTab keytab, String principal, String keytabPath)
+ throws ConfigurationException {
+ try {
+ Principal kerberosPrincipal = new KerberosPrincipal(principal);
+ if (keytab != null) {
+ KerberosKey[] keys = keytab.getKeys((KerberosPrincipal) kerberosPrincipal);
+ if (keys.length == 0) {
+ throw new ConfigurationException(
+ String.format(
+ "SQL Gateway REST authentication Kerberos keytab '%s' does not contain principal '%s'.",
+ keytabPath, principal));
+ }
+ for (KerberosKey key : keys) {
+ key.destroy();
+ }
+ }
+ serverSubject.getPrincipals().add(kerberosPrincipal);
+ LOG.info(
+ "Using SQL Gateway REST SPNEGO keytab {} for principal {}.",
+ keytabPath,
+ principal);
+ } catch (IllegalArgumentException e) {
+ throw new ConfigurationException(
+ "Invalid SQL Gateway REST authentication Kerberos principal: " + principal, e);
+ } catch (javax.security.auth.DestroyFailedException e) {
+ throw new ConfigurationException(
+ "Could not validate SQL Gateway REST authentication Kerberos keytab.", e);
+ }
+ }
+}
diff --git a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/util/SqlGatewayRestOptions.java b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/util/SqlGatewayRestOptions.java
index fc440b13523be..04e90bb167924 100644
--- a/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/util/SqlGatewayRestOptions.java
+++ b/flink-table/flink-sql-gateway/src/main/java/org/apache/flink/table/gateway/rest/util/SqlGatewayRestOptions.java
@@ -22,6 +22,8 @@
import org.apache.flink.configuration.ConfigOption;
import org.apache.flink.table.gateway.rest.SqlGatewayRestEndpoint;
+import java.time.Duration;
+
import static org.apache.flink.configuration.ConfigOptions.key;
/**
@@ -43,6 +45,12 @@
@PublicEvolving
public class SqlGatewayRestOptions {
+ /** Authentication mechanism for the SQL Gateway REST endpoint. */
+ public enum SqlGatewayRestAuthenticationType {
+ NONE,
+ KERBEROS
+ }
+
/** The address that should be used by clients to connect to the sql gateway server. */
public static final ConfigOption ADDRESS =
key("address")
@@ -77,4 +85,72 @@ public class SqlGatewayRestOptions {
String.format(
"The port that the client connects to. If %s has not been specified, then the sql gateway server will bind to this port.",
BIND_PORT.key()));
+
+ /** Authentication type used by the SQL Gateway REST endpoint. */
+ public static final ConfigOption AUTHENTICATION_TYPE =
+ key("authentication.type")
+ .enumType(SqlGatewayRestAuthenticationType.class)
+ .defaultValue(SqlGatewayRestAuthenticationType.NONE)
+ .withDescription(
+ "The authentication type for the SQL Gateway REST endpoint. "
+ + "Supported values are NONE and KERBEROS.");
+
+ /** Kerberos principal used by the SQL Gateway REST endpoint SPNEGO acceptor. */
+ public static final ConfigOption AUTHENTICATION_KERBEROS_PRINCIPAL =
+ key("authentication.kerberos.principal")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Kerberos principal used by the SQL Gateway REST endpoint SPNEGO acceptor. "
+ + "The HTTP/_HOST@REALM pattern is supported, and '*' uses all HTTP principals from the keytab.");
+
+ /** Kerberos keytab used by the SQL Gateway REST endpoint SPNEGO acceptor. */
+ public static final ConfigOption AUTHENTICATION_KERBEROS_KEYTAB =
+ key("authentication.kerberos.keytab")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Kerberos keytab used by the SQL Gateway REST endpoint SPNEGO acceptor.");
+
+ /** Kerberos auth-to-local rules used by the SQL Gateway REST endpoint. */
+ public static final ConfigOption AUTHENTICATION_KERBEROS_NAME_RULES =
+ key("authentication.kerberos.name-rules")
+ .stringType()
+ .defaultValue("DEFAULT")
+ .withDescription(
+ "Kerberos auth-to-local rules used to derive the local user name from the authenticated Kerberos principal.");
+
+ /** Validity period of the SQL Gateway REST authentication cookie. */
+ public static final ConfigOption AUTHENTICATION_TOKEN_VALIDITY =
+ key("authentication.token.validity")
+ .durationType()
+ .defaultValue(Duration.ofHours(10))
+ .withDescription(
+ "Validity period of the SQL Gateway REST authentication cookie.");
+
+ /** Cookie path used for SQL Gateway REST authentication cookies. */
+ public static final ConfigOption AUTHENTICATION_COOKIE_PATH =
+ key("authentication.cookie.path")
+ .stringType()
+ .defaultValue("/")
+ .withDescription(
+ "Cookie path used for SQL Gateway REST authentication cookies.");
+
+ /** Shared secret used to sign SQL Gateway REST authentication cookies. */
+ public static final ConfigOption AUTHENTICATION_SIGNATURE_SECRET =
+ key("authentication.signature.secret")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "Shared secret used to sign SQL Gateway REST authentication cookies. "
+ + "If neither this option nor authentication.signature.secret-file is configured, a random per-process secret is used.");
+
+ /** File containing the shared secret used to sign SQL Gateway REST authentication cookies. */
+ public static final ConfigOption AUTHENTICATION_SIGNATURE_SECRET_FILE =
+ key("authentication.signature.secret-file")
+ .stringType()
+ .noDefaultValue()
+ .withDescription(
+ "File containing the shared secret used to sign SQL Gateway REST authentication cookies. "
+ + "If neither this option nor authentication.signature.secret is configured, a random per-process secret is used.");
}
diff --git a/flink-table/flink-sql-gateway/src/main/resources/META-INF/NOTICE b/flink-table/flink-sql-gateway/src/main/resources/META-INF/NOTICE
index fd52ca62cf0ff..30f9ccec7b648 100644
--- a/flink-table/flink-sql-gateway/src/main/resources/META-INF/NOTICE
+++ b/flink-table/flink-sql-gateway/src/main/resources/META-INF/NOTICE
@@ -6,4 +6,16 @@ The Apache Software Foundation (http://www.apache.org/).
This project bundles the following dependencies under the Apache Software License 2.0. (http://www.apache.org/licenses/LICENSE-2.0.txt)
+- commons-codec:commons-codec:1.15
+- org.apache.hadoop:hadoop-auth:3.4.3.1-4.3.0-1
+- org.apache.hadoop.thirdparty:hadoop-shaded-guava:1.6.0.1-4.3.0-0
+- org.apache.httpcomponents:httpclient:4.5.13
+- org.apache.httpcomponents:httpcore:4.4.14
+- org.apache.kerby:kerb-core:2.0.3
+- org.apache.kerby:kerb-crypto:2.0.3
+- org.apache.kerby:kerb-util:2.0.3
+- org.apache.kerby:kerby-asn1:2.0.3
+- org.apache.kerby:kerby-config:2.0.3
+- org.apache.kerby:kerby-pkix:2.0.3
+- org.apache.kerby:kerby-util:2.0.3
- org.quartz-scheduler:quartz:2.3.2
diff --git a/flink-table/flink-sql-gateway/src/main/resources/META-INF/services/org.apache.flink.runtime.io.network.netty.InboundChannelHandlerFactory b/flink-table/flink-sql-gateway/src/main/resources/META-INF/services/org.apache.flink.runtime.io.network.netty.InboundChannelHandlerFactory
new file mode 100644
index 0000000000000..fbcafd1f4891c
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/main/resources/META-INF/services/org.apache.flink.runtime.io.network.netty.InboundChannelHandlerFactory
@@ -0,0 +1,16 @@
+# 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.
+
+org.apache.flink.table.gateway.rest.security.SqlGatewaySpnegoAuthenticationHandlerFactory
diff --git a/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewayAuthenticationTokenSignerTest.java b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewayAuthenticationTokenSignerTest.java
new file mode 100644
index 0000000000000..74c8c7ad05547
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewayAuthenticationTokenSignerTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link SqlGatewayAuthenticationTokenSigner}. */
+class SqlGatewayAuthenticationTokenSignerTest {
+
+ private static final Clock NOW = Clock.fixed(Instant.ofEpochMilli(10_000L), ZoneOffset.UTC);
+
+ @Test
+ void shouldSignAndVerifyKerberosToken() {
+ SqlGatewayAuthenticationTokenSigner signer =
+ new SqlGatewayAuthenticationTokenSigner(
+ "secret".getBytes(), Duration.ofHours(10), NOW);
+
+ String signedToken = signer.signToken("alice", "alice@EXAMPLE.COM");
+
+ Optional token = signer.verifyToken(signedToken);
+ assertThat(token).isPresent();
+ assertThat(token.orElseThrow().getUserName()).isEqualTo("alice");
+ assertThat(token.orElseThrow().getName()).isEqualTo("alice@EXAMPLE.COM");
+ assertThat(token.orElseThrow().getType())
+ .isEqualTo(SqlGatewayAuthenticationTokenSigner.TOKEN_TYPE_KERBEROS);
+ }
+
+ @Test
+ void shouldRejectInvalidSignature() {
+ SqlGatewayAuthenticationTokenSigner signer =
+ new SqlGatewayAuthenticationTokenSigner(
+ "secret".getBytes(), Duration.ofHours(10), NOW);
+
+ String signedToken = signer.signToken("alice", "alice@EXAMPLE.COM");
+
+ assertThat(signer.verifyToken(signedToken.replace("alice", "bob"))).isEmpty();
+ }
+
+ @Test
+ void shouldRejectExpiredToken() {
+ SqlGatewayAuthenticationTokenSigner issuingSigner =
+ new SqlGatewayAuthenticationTokenSigner(
+ "secret".getBytes(), Duration.ofMillis(1), NOW);
+ SqlGatewayAuthenticationTokenSigner verifyingSigner =
+ new SqlGatewayAuthenticationTokenSigner(
+ "secret".getBytes(),
+ Duration.ofMillis(1),
+ Clock.offset(NOW, Duration.ofMillis(2)));
+
+ String signedToken = issuingSigner.signToken("alice", "alice@EXAMPLE.COM");
+
+ assertThat(verifyingSigner.verifyToken(signedToken)).isEmpty();
+ }
+}
diff --git a/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewayRestAuthenticationConfigTest.java b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewayRestAuthenticationConfigTest.java
new file mode 100644
index 0000000000000..5624bcb16ee71
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewayRestAuthenticationConfigTest.java
@@ -0,0 +1,176 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.RestOptions;
+import org.apache.flink.table.gateway.api.endpoint.SqlGatewayEndpointFactoryUtils;
+import org.apache.flink.table.gateway.rest.SqlGatewayRestEndpointFactory;
+import org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions;
+import org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions.SqlGatewayRestAuthenticationType;
+import org.apache.flink.util.ConfigurationException;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link SqlGatewayRestAuthenticationConfig}. */
+class SqlGatewayRestAuthenticationConfigTest {
+
+ @Test
+ void shouldDefaultToNone() throws Exception {
+ SqlGatewayRestAuthenticationConfig config =
+ SqlGatewayRestAuthenticationConfig.fromConfiguration(baseConfiguration());
+
+ assertThat(config.type()).isEqualTo(SqlGatewayRestAuthenticationType.NONE);
+ assertThat(config.isKerberosEnabled()).isFalse();
+ }
+
+ @Test
+ void shouldNotInstallHandlerWhenAuthenticationIsDisabled() throws Exception {
+ assertThat(
+ new SqlGatewaySpnegoAuthenticationHandlerFactory()
+ .createHandler(baseConfiguration(), Collections.emptyMap()))
+ .isEmpty();
+ }
+
+ @Test
+ void shouldRequirePrincipalForKerberos() {
+ Configuration configuration = baseConfiguration();
+ set(configuration, SqlGatewayRestOptions.AUTHENTICATION_TYPE.key(), "KERBEROS");
+ set(configuration, SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_KEYTAB.key(), "/tmp/a");
+
+ assertThatThrownBy(
+ () -> SqlGatewayRestAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("authentication.kerberos.principal");
+ }
+
+ @Test
+ void shouldRequireKeytabForKerberos() {
+ Configuration configuration = baseConfiguration();
+ set(configuration, SqlGatewayRestOptions.AUTHENTICATION_TYPE.key(), "KERBEROS");
+ set(
+ configuration,
+ SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_PRINCIPAL.key(),
+ "HTTP/localhost@EXAMPLE.COM");
+
+ assertThatThrownBy(
+ () -> SqlGatewayRestAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("authentication.kerberos.keytab");
+ }
+
+ @Test
+ void shouldRejectBothInlineSecretAndSecretFile(@TempDir Path tempDir) throws Exception {
+ Path secretFile = tempDir.resolve("secret");
+ Files.writeString(secretFile, "secret-file", StandardCharsets.UTF_8);
+
+ Configuration configuration = baseConfiguration();
+ set(configuration, SqlGatewayRestOptions.AUTHENTICATION_TYPE.key(), "KERBEROS");
+ set(
+ configuration,
+ SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_PRINCIPAL.key(),
+ "HTTP/localhost@EXAMPLE.COM");
+ set(configuration, SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_KEYTAB.key(), "/tmp/a");
+ set(configuration, SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET.key(), "secret");
+ set(
+ configuration,
+ SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET_FILE.key(),
+ secretFile.toString());
+
+ assertThatThrownBy(
+ () -> SqlGatewayRestAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(ConfigurationException.class)
+ .hasMessageContaining("signature.secret")
+ .hasMessageContaining("signature.secret-file");
+ }
+
+ @Test
+ void shouldReadSecretFile(@TempDir Path tempDir) throws Exception {
+ Path secretFile = tempDir.resolve("secret");
+ Files.writeString(secretFile, "secret-file\n", StandardCharsets.UTF_8);
+
+ Configuration configuration = kerberosConfiguration();
+ set(
+ configuration,
+ SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET_FILE.key(),
+ secretFile.toString());
+
+ SqlGatewayRestAuthenticationConfig config =
+ SqlGatewayRestAuthenticationConfig.fromConfiguration(configuration);
+
+ assertThat(new String(config.signatureSecret(), StandardCharsets.UTF_8))
+ .isEqualTo("secret-file");
+ }
+
+ @Test
+ void shouldReplaceHostPattern() throws Exception {
+ String principal =
+ SqlGatewaySpnegoAuthenticator.replaceHostPattern(
+ "HTTP/_HOST@EXAMPLE.COM", "localhost");
+
+ assertThat(principal).startsWith("HTTP/");
+ assertThat(principal).endsWith("@EXAMPLE.COM");
+ assertThat(principal).doesNotContain("_HOST");
+ }
+
+ @Test
+ void shouldRejectInvalidAuthenticationType() {
+ Configuration configuration = baseConfiguration();
+ set(configuration, SqlGatewayRestOptions.AUTHENTICATION_TYPE.key(), "BASIC");
+
+ assertThatThrownBy(
+ () -> SqlGatewayRestAuthenticationConfig.fromConfiguration(configuration))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("BASIC");
+ }
+
+ private static Configuration kerberosConfiguration() {
+ Configuration configuration = baseConfiguration();
+ set(configuration, SqlGatewayRestOptions.AUTHENTICATION_TYPE.key(), "KERBEROS");
+ set(
+ configuration,
+ SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_PRINCIPAL.key(),
+ "HTTP/localhost@EXAMPLE.COM");
+ set(configuration, SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_KEYTAB.key(), "/tmp/a");
+ return configuration;
+ }
+
+ private static Configuration baseConfiguration() {
+ Configuration configuration = new Configuration();
+ configuration.set(RestOptions.ADDRESS, "localhost");
+ return configuration;
+ }
+
+ private static void set(Configuration configuration, String localKey, String value) {
+ configuration.setString(
+ SqlGatewayEndpointFactoryUtils.getSqlGatewayOptionPrefix(
+ SqlGatewayRestEndpointFactory.IDENTIFIER)
+ + localKey,
+ value);
+ }
+}
diff --git a/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationHandlerTest.java b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationHandlerTest.java
new file mode 100644
index 0000000000000..fedec6d7b4a41
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoAuthenticationHandlerTest.java
@@ -0,0 +1,206 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.flink.shaded.netty4.io.netty.channel.embedded.EmbeddedChannel;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.DefaultFullHttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.DefaultFullHttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.FullHttpRequest;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.FullHttpResponse;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpHeaderNames;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpMethod;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpResponseStatus;
+import org.apache.flink.shaded.netty4.io.netty.handler.codec.http.HttpVersion;
+
+import org.apache.hadoop.security.authentication.client.AuthenticationException;
+import org.apache.hadoop.security.authentication.server.AuthenticationToken;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link SqlGatewaySpnegoAuthenticationHandler}. */
+class SqlGatewaySpnegoAuthenticationHandlerTest {
+
+ private static final byte[] SECRET = "secret".getBytes();
+ private static final Clock NOW = Clock.fixed(Instant.ofEpochMilli(10_000L), ZoneOffset.UTC);
+
+ @Test
+ void shouldChallengeUnauthenticatedRequest() {
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ SqlGatewaySpnegoAuthenticationResult.challenge("Negotiate"));
+
+ channel.writeInbound(request());
+
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.UNAUTHORIZED);
+ assertThat(response.headers().get(HttpHeaderNames.WWW_AUTHENTICATE))
+ .isEqualTo("Negotiate");
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldRejectInvalidSpnegoToken() {
+ EmbeddedChannel channel =
+ channel(
+ authorization -> {
+ throw new AuthenticationException("bad token");
+ });
+
+ FullHttpRequest request = request();
+ request.headers().set(HttpHeaderNames.AUTHORIZATION, "Negotiate invalid");
+ channel.writeInbound(request);
+
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.FORBIDDEN);
+ assertThat(response.headers().contains(HttpHeaderNames.WWW_AUTHENTICATE)).isFalse();
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldPassThroughValidCookie() {
+ SqlGatewayAuthenticationTokenSigner signer = signer(NOW);
+ String signedToken = signer.signToken("alice", "alice@EXAMPLE.COM");
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ SqlGatewaySpnegoAuthenticationResult.challenge("Negotiate"));
+
+ FullHttpRequest request = request();
+ request.headers()
+ .set(
+ HttpHeaderNames.COOKIE,
+ SqlGatewaySpnegoAuthenticationHandler.HADOOP_AUTH_COOKIE
+ + "=\""
+ + signedToken
+ + "\"");
+ channel.writeInbound(request);
+
+ FullHttpRequest forwarded = channel.readInbound();
+ try {
+ assertThat(forwarded.uri()).isEqualTo("/v1/info");
+ assertThat((Object) channel.readOutbound()).isNull();
+ } finally {
+ forwarded.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldIgnoreExpiredCookieAndRestartChallenge() {
+ String expiredToken = signer(NOW).signToken("alice", "alice@EXAMPLE.COM");
+ EmbeddedChannel channel =
+ new EmbeddedChannel(
+ handler(
+ authorization ->
+ SqlGatewaySpnegoAuthenticationResult.challenge("Negotiate"),
+ new SqlGatewayAuthenticationTokenSigner(
+ SECRET,
+ Duration.ofMillis(1),
+ Clock.offset(NOW, Duration.ofMillis(2)))));
+
+ FullHttpRequest request = request();
+ request.headers()
+ .set(
+ HttpHeaderNames.COOKIE,
+ SqlGatewaySpnegoAuthenticationHandler.HADOOP_AUTH_COOKIE
+ + "=\""
+ + expiredToken
+ + "\"");
+ channel.writeInbound(request);
+
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.status()).isEqualTo(HttpResponseStatus.UNAUTHORIZED);
+ assertThat(response.headers().get(HttpHeaderNames.WWW_AUTHENTICATE))
+ .isEqualTo("Negotiate");
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ @Test
+ void shouldIssueCookieAfterSuccessfulSpnegoAuthentication() {
+ EmbeddedChannel channel =
+ channel(
+ authorization ->
+ SqlGatewaySpnegoAuthenticationResult.authenticated(
+ new AuthenticationToken(
+ "alice",
+ "alice@EXAMPLE.COM",
+ SqlGatewayAuthenticationTokenSigner
+ .TOKEN_TYPE_KERBEROS),
+ "Negotiate server-token"));
+
+ FullHttpRequest request = request();
+ request.headers().set(HttpHeaderNames.AUTHORIZATION, "Negotiate client-token");
+ channel.writeInbound(request);
+ FullHttpRequest forwarded = channel.readInbound();
+ forwarded.release();
+
+ channel.writeOutbound(
+ new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK));
+ FullHttpResponse response = channel.readOutbound();
+ try {
+ assertThat(response.headers().get(HttpHeaderNames.SET_COOKIE))
+ .contains(SqlGatewaySpnegoAuthenticationHandler.HADOOP_AUTH_COOKIE + "=\"")
+ .contains("; Path=/")
+ .contains("; HttpOnly");
+ assertThat(response.headers().get(HttpHeaderNames.WWW_AUTHENTICATE))
+ .isEqualTo("Negotiate server-token");
+ } finally {
+ response.release();
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ private static EmbeddedChannel channel(SpnegoAuthenticator authenticator) {
+ return new EmbeddedChannel(handler(authenticator, signer(NOW)));
+ }
+
+ private static SqlGatewaySpnegoAuthenticationHandler handler(
+ SpnegoAuthenticator authenticator, SqlGatewayAuthenticationTokenSigner signer) {
+ return new SqlGatewaySpnegoAuthenticationHandler(
+ authenticator, signer, "/", false, Collections.emptyMap());
+ }
+
+ private static SqlGatewayAuthenticationTokenSigner signer(Clock clock) {
+ return new SqlGatewayAuthenticationTokenSigner(SECRET, Duration.ofMillis(1), clock);
+ }
+
+ private static FullHttpRequest request() {
+ return new DefaultFullHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.GET, "/v1/info");
+ }
+}
diff --git a/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoITCase.java b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoITCase.java
new file mode 100644
index 0000000000000..43cee9b11e86d
--- /dev/null
+++ b/flink-table/flink-sql-gateway/src/test/java/org/apache/flink/table/gateway/rest/security/SqlGatewaySpnegoITCase.java
@@ -0,0 +1,277 @@
+/*
+ * 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.flink.table.gateway.rest.security;
+
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.runtime.security.KerberosUtils;
+import org.apache.flink.table.gateway.api.session.SessionEnvironment;
+import org.apache.flink.table.gateway.api.session.SessionHandle;
+import org.apache.flink.table.gateway.api.utils.MockedSqlGatewayService;
+import org.apache.flink.table.gateway.rest.SqlGatewayRestEndpoint;
+import org.apache.flink.table.gateway.rest.util.SqlGatewayRestOptions;
+
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+import org.apache.hadoop.minikdc.MiniKdc;
+import org.apache.hadoop.security.authentication.util.KerberosUtil;
+import org.ietf.jgss.GSSContext;
+import org.ietf.jgss.GSSManager;
+import org.ietf.jgss.GSSName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import javax.security.auth.Subject;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.LoginContext;
+
+import java.net.InetSocketAddress;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.PrivilegedExceptionAction;
+import java.util.Base64;
+import java.util.Properties;
+
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestEndpointTestUtils.getBaseConfig;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestEndpointTestUtils.getFlinkConfig;
+import static org.apache.flink.table.gateway.rest.util.SqlGatewayRestEndpointTestUtils.getSqlGatewayRestOptionFullName;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Integration tests for SQL Gateway REST SPNEGO authentication. */
+class SqlGatewaySpnegoITCase {
+
+ private static final MediaType JSON = MediaType.parse("application/json");
+ private static final String HOST = "localhost";
+ private static final String CLIENT_PRINCIPAL_NAME = "alice";
+ private static final String HTTP_SERVICE_PRINCIPAL_NAME = "HTTP/" + HOST;
+
+ @Test
+ void shouldRequireSpnegoAndAllowKerberosClient(@TempDir Path tempDir) throws Exception {
+ Properties previousProperties = rememberKerberosProperties();
+ MiniKdc kdc = null;
+ SqlGatewayRestEndpoint endpoint = null;
+ LoginContext loginContext = null;
+ try {
+ Path kdcDir = tempDir.resolve("kdc");
+ Files.createDirectories(kdcDir);
+ Properties kdcConf = MiniKdc.createConf();
+ kdcConf.setProperty(MiniKdc.KDC_BIND_ADDRESS, HOST);
+ kdc = new MiniKdc(kdcConf, kdcDir.toFile());
+ kdc.start();
+ System.setProperty(MiniKdc.JAVA_SECURITY_KRB5_CONF, kdc.getKrb5conf().toString());
+ System.setProperty("sun.security.krb5.disableReferrals", "true");
+
+ Path serverKeytab = tempDir.resolve("server.keytab");
+ Path clientKeytab = tempDir.resolve("client.keytab");
+ kdc.createPrincipal(serverKeytab.toFile(), HTTP_SERVICE_PRINCIPAL_NAME);
+ kdc.createPrincipal(clientKeytab.toFile(), CLIENT_PRINCIPAL_NAME);
+
+ Configuration flinkConfig = getFlinkConfig(HOST, HOST, "0");
+ setAuthOption(flinkConfig, SqlGatewayRestOptions.AUTHENTICATION_TYPE.key(), "KERBEROS");
+ setAuthOption(
+ flinkConfig,
+ SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_PRINCIPAL.key(),
+ "*");
+ setAuthOption(
+ flinkConfig,
+ SqlGatewayRestOptions.AUTHENTICATION_KERBEROS_KEYTAB.key(),
+ serverKeytab.toString());
+ setAuthOption(
+ flinkConfig,
+ SqlGatewayRestOptions.AUTHENTICATION_SIGNATURE_SECRET.key(),
+ "shared-test-secret");
+
+ Configuration endpointConfig = getBaseConfig(flinkConfig);
+ SqlGatewaySpnegoAuthenticationHandlerFactory.validateConfiguration(endpointConfig);
+ endpoint = new SqlGatewayRestEndpoint(endpointConfig, new TestingSqlGatewayService());
+ endpoint.start();
+ String baseUrl = getBaseUrl(endpoint);
+
+ OkHttpClient httpClient = new OkHttpClient();
+ assertSpnegoChallenge(httpClient, new Request.Builder().url(baseUrl + "/v1/info"));
+ assertSpnegoChallenge(
+ httpClient,
+ new Request.Builder()
+ .url(baseUrl + "/v1/sessions")
+ .post(RequestBody.create(JSON, "{}")));
+
+ loginContext =
+ loginFromKeytab(CLIENT_PRINCIPAL_NAME + "@" + kdc.getRealm(), clientKeytab);
+ try (Response infoResponse =
+ spnegoRequest(
+ httpClient,
+ new Request.Builder().url(baseUrl + "/v1/info"),
+ loginContext.getSubject())) {
+ assertThat(infoResponse.code()).isEqualTo(200);
+ assertThat(infoResponse.header("Set-Cookie")).contains("hadoop.auth=");
+ }
+
+ try (Response openSessionResponse =
+ spnegoRequest(
+ httpClient,
+ new Request.Builder()
+ .url(baseUrl + "/v1/sessions")
+ .post(RequestBody.create(JSON, "{}")),
+ loginContext.getSubject())) {
+ assertThat(openSessionResponse.code()).isEqualTo(200);
+ assertThat(openSessionResponse.body().string()).contains("sessionHandle");
+ assertThat(openSessionResponse.header("Set-Cookie")).contains("hadoop.auth=");
+ }
+ } finally {
+ if (loginContext != null) {
+ loginContext.logout();
+ }
+ if (endpoint != null) {
+ endpoint.stop();
+ }
+ if (kdc != null) {
+ kdc.stop();
+ }
+ restoreKerberosProperties(previousProperties);
+ }
+ }
+
+ private static void assertSpnegoChallenge(OkHttpClient httpClient, Request.Builder builder)
+ throws Exception {
+ try (Response response = httpClient.newCall(builder.build()).execute()) {
+ assertThat(response.code()).isEqualTo(401);
+ assertThat(response.header("WWW-Authenticate")).isEqualTo("Negotiate");
+ }
+ }
+
+ private static Response spnegoRequest(
+ OkHttpClient httpClient, Request.Builder builder, Subject clientSubject)
+ throws Exception {
+ return Subject.doAs(
+ clientSubject,
+ (PrivilegedExceptionAction)
+ () -> {
+ GSSManager gssManager = GSSManager.getInstance();
+ GSSName serverName =
+ gssManager.createName(
+ "HTTP@" + HOST, GSSName.NT_HOSTBASED_SERVICE);
+ GSSContext gssContext =
+ gssManager.createContext(
+ serverName,
+ KerberosUtil.GSS_SPNEGO_MECH_OID,
+ null,
+ GSSContext.DEFAULT_LIFETIME);
+ try {
+ gssContext.requestMutualAuth(true);
+ byte[] clientToken = new byte[0];
+ for (int i = 0; i < 2; i++) {
+ byte[] outputToken =
+ gssContext.initSecContext(
+ clientToken, 0, clientToken.length);
+ Request request =
+ builder.header(
+ "Authorization",
+ "Negotiate "
+ + Base64.getEncoder()
+ .encodeToString(
+ outputToken))
+ .build();
+ Response response = httpClient.newCall(request).execute();
+ if (response.code() != 401) {
+ return response;
+ }
+ String authenticateHeader =
+ response.header("WWW-Authenticate", "Negotiate");
+ response.close();
+ clientToken = decodeServerToken(authenticateHeader);
+ }
+ throw new IllegalStateException(
+ "SPNEGO authentication did not complete.");
+ } finally {
+ gssContext.dispose();
+ }
+ });
+ }
+
+ private static byte[] decodeServerToken(String authenticateHeader) {
+ String prefix = "Negotiate ";
+ if (!authenticateHeader.startsWith(prefix)) {
+ return new byte[0];
+ }
+ return Base64.getDecoder().decode(authenticateHeader.substring(prefix.length()));
+ }
+
+ private static LoginContext loginFromKeytab(String principal, Path keytab) throws Exception {
+ LoginContext loginContext =
+ new LoginContext(
+ "client",
+ null,
+ null,
+ new javax.security.auth.login.Configuration() {
+ @Override
+ public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
+ return new AppConfigurationEntry[] {
+ KerberosUtils.keytabEntry(keytab.toString(), principal)
+ };
+ }
+ });
+ loginContext.login();
+ return loginContext;
+ }
+
+ private static String getBaseUrl(SqlGatewayRestEndpoint endpoint) {
+ InetSocketAddress serverAddress = endpoint.getServerAddress();
+ return "http://" + serverAddress.getHostString() + ":" + serverAddress.getPort();
+ }
+
+ private static void setAuthOption(Configuration configuration, String localKey, String value) {
+ configuration.setString(getSqlGatewayRestOptionFullName(localKey), value);
+ }
+
+ private static Properties rememberKerberosProperties() {
+ Properties properties = new Properties();
+ remember(properties, MiniKdc.JAVA_SECURITY_KRB5_CONF);
+ remember(properties, "sun.security.krb5.disableReferrals");
+ return properties;
+ }
+
+ private static void remember(Properties properties, String key) {
+ String value = System.getProperty(key);
+ if (value != null) {
+ properties.setProperty(key, value);
+ }
+ }
+
+ private static void restoreKerberosProperties(Properties properties) {
+ restore(properties, MiniKdc.JAVA_SECURITY_KRB5_CONF);
+ restore(properties, "sun.security.krb5.disableReferrals");
+ }
+
+ private static void restore(Properties properties, String key) {
+ if (properties.containsKey(key)) {
+ System.setProperty(key, properties.getProperty(key));
+ } else {
+ System.clearProperty(key);
+ }
+ }
+
+ private static final class TestingSqlGatewayService extends MockedSqlGatewayService {
+ @Override
+ public SessionHandle openSession(SessionEnvironment environment) {
+ return SessionHandle.create();
+ }
+ }
+}
diff --git a/pom.xml b/pom.xml
index 5428b2d1a886a..831910b373344 100644
--- a/pom.xml
+++ b/pom.xml
@@ -417,6 +417,18 @@ under the License.
org.slf4j
slf4j-log4j12
+
+ ch.qos.reload4j
+ reload4j
+
+
+ org.slf4j
+ slf4j-reload4j
+
+
+ io.dropwizard.metrics
+ metrics-core
+