diff --git a/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java b/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java
index 31b5e32c2ddb..6e9af4e17715 100644
--- a/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java
+++ b/common/src/java/org/apache/hadoop/hive/conf/HiveConf.java
@@ -3865,6 +3865,12 @@ public static enum ConfVars {
+ " for HiveServer2 WebUI."),
HIVE_SERVER2_WEBUI_SSL_KEYMANAGERFACTORY_ALGORITHM("hive.server2.webui.keymanagerfactory.algorithm",
"","SSL certificate key manager factory algorithm for HiveServer2 WebUI."),
+ HIVE_SERVER2_WEBUI_USE_CUSTOM_AUTH_FILTER("hive.server2.webui.use.custom.auth.filter", false,
+ "If true, the HiveServer2 WebUI will be secured with custom auth filter"),
+ HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER("hive.server2.webui.custom.auth.filter", "",
+ "Filter class name to apply to the Web UI. The filter should be a standard javax servlet Filter. "
+ + "Filter parameters can also be specified in the configuration, by setting config entries of the form "
+ + "hive.server2.webui.custom.auth.filter.param.="),
HIVE_SERVER2_WEBUI_USE_SPNEGO("hive.server2.webui.use.spnego", false,
"If true, the HiveServer2 WebUI will be secured with SPNEGO. Clients must authenticate with Kerberos."),
HIVE_SERVER2_WEBUI_SPNEGO_KEYTAB("hive.server2.webui.spnego.keytab", "",
diff --git a/common/src/java/org/apache/hive/http/HttpServer.java b/common/src/java/org/apache/hive/http/HttpServer.java
index dd9e66f92b6b..52a003d3d194 100644
--- a/common/src/java/org/apache/hive/http/HttpServer.java
+++ b/common/src/java/org/apache/hive/http/HttpServer.java
@@ -51,6 +51,7 @@
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import org.apache.commons.lang3.StringUtils;
@@ -170,6 +171,12 @@ public static class Builder {
private boolean useSPNEGO;
private boolean useSSL;
private boolean usePAM;
+ @VisibleForTesting
+ public boolean useCustomAuthFilter;
+ @VisibleForTesting
+ public String customAuthFilter;
+ @VisibleForTesting
+ public Map customAuthFilterParams;
private boolean enableCORS;
private String allowedOrigins;
private String allowedMethods;
@@ -277,6 +284,21 @@ public Builder setUseSPNEGO(boolean useSPNEGO) {
return this;
}
+ public Builder setUseCustomAuthFilter(boolean useCustomAuthFilter) {
+ this.useCustomAuthFilter = useCustomAuthFilter;
+ return this;
+ }
+
+ public Builder setCustomAuthFilter(String customAuthFilter) {
+ this.customAuthFilter = customAuthFilter;
+ return this;
+ }
+
+ public Builder setCustomAuthFilterParams(Map customAuthFilterParams) {
+ this.customAuthFilterParams = customAuthFilterParams;
+ return this;
+ }
+
public Builder setEnableCORS(boolean enableCORS) {
this.enableCORS = enableCORS;
return this;
@@ -531,6 +553,19 @@ void setupSpnegoFilter(Builder b, ServletContextHandler ctx) throws IOException
holder, "/*", FilterMapping.ALL);
}
+ /**
+ * Secure the web server with a custom {@link javax.servlet.Filter}.
+ * The filter class name and its init parameters come from the {@link Builder}.
+ */
+ void setupCustomAuthFilter(Builder b, ServletContextHandler ctx) {
+ FilterHolder holder = new FilterHolder();
+ holder.setClassName(b.customAuthFilter);
+ holder.setInitParameters(b.customAuthFilterParams);
+ ServletHandler handler = ctx.getServletHandler();
+ handler.addFilterWithMapping(
+ holder, "/*", FilterMapping.ALL);
+ }
+
/**
* Setup cross-origin requests (CORS) filter.
* @param b - builder
@@ -607,6 +642,10 @@ private void initWebAppContext(Builder builder, WebAppContext webAppContext) thr
setupSpnegoFilter(builder, webAppContext);
}
+ if (builder.useCustomAuthFilter) {
+ setupCustomAuthFilter(builder, webAppContext);
+ }
+
if (builder.enableCORS) {
setupCORSFilter(builder, webAppContext);
}
@@ -796,6 +835,9 @@ private void initializeWebServer(final Builder b) throws IOException {
if(b.useSPNEGO) {
setupSpnegoFilter(b,logCtx);
}
+ if (b.useCustomAuthFilter) {
+ setupCustomAuthFilter(b, logCtx);
+ }
logCtx.addServlet(AdminAuthorizedServlet.class, "/*");
logCtx.setResourceBase(logDir);
logCtx.setDisplayName("logs");
diff --git a/common/src/test/org/apache/hive/http/TestHttpServerCustomAuthFilter.java b/common/src/test/org/apache/hive/http/TestHttpServerCustomAuthFilter.java
new file mode 100644
index 000000000000..2f9dbfacf19d
--- /dev/null
+++ b/common/src/test/org/apache/hive/http/TestHttpServerCustomAuthFilter.java
@@ -0,0 +1,231 @@
+/*
+ * 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.hive.http;
+
+import org.apache.hadoop.hive.conf.HiveConf;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.HttpURLConnection;
+import java.net.ServerSocket;
+import java.net.URL;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * End-to-end test that wires a sample {@link Filter} into HttpServer via
+ * the custom-auth-filter Builder API and verifies that real HTTP requests
+ * to the running server flow through the filter — and that the filter's
+ * decision (pass through or short-circuit with 401) is honored.
+ */
+public class TestHttpServerCustomAuthFilter {
+
+ private HttpServer server;
+
+ @Before
+ public void resetCounters() {
+ RecordingFilter.reset();
+ BlockingFilter.reset();
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ if (server != null) {
+ server.stop();
+ server = null;
+ }
+ }
+
+ /**
+ * With {@code useCustomAuthFilter=true} and a passthrough filter, requests
+ * reach the underlying servlet AND the filter sees them, including the
+ * configured init parameters.
+ */
+ @Test(timeout = 30_000)
+ public void testCustomAuthFilterInterceptsRequests() throws Exception {
+ Map params = new HashMap<>();
+ params.put("realm", "hive");
+ params.put("ttl", "600");
+
+ int port = freePort();
+ server = new HttpServer.Builder("test")
+ .setConf(new HiveConf())
+ .setHost("localhost")
+ .setPort(port)
+ .setUseCustomAuthFilter(true)
+ .setCustomAuthFilter(RecordingFilter.class.getName())
+ .setCustomAuthFilterParams(params)
+ .build();
+ server.start();
+
+ int code = doGet("/jmx");
+ assertEquals("Passthrough filter should not block the request", 200, code);
+
+ assertTrue("Filter should have been invoked at least once; was "
+ + RecordingFilter.callCount.get(), RecordingFilter.callCount.get() >= 1);
+ assertEquals("Init params should be threaded through to the filter",
+ "hive", RecordingFilter.initParams.get("realm"));
+ assertEquals("600", RecordingFilter.initParams.get("ttl"));
+ }
+
+ /**
+ * The filter's decision is authoritative: when the filter short-circuits
+ * with a 401, the underlying servlet is never reached and the client
+ * receives the 401 response code.
+ */
+ @Test(timeout = 30_000)
+ public void testCustomAuthFilterCanBlockRequest() throws Exception {
+ int port = freePort();
+ server = new HttpServer.Builder("test")
+ .setConf(new HiveConf())
+ .setHost("localhost")
+ .setPort(port)
+ .setUseCustomAuthFilter(true)
+ .setCustomAuthFilter(BlockingFilter.class.getName())
+ .setCustomAuthFilterParams(new HashMap<>())
+ .build();
+ server.start();
+
+ int code = doGet("/jmx");
+ assertEquals("Blocking filter should short-circuit with 401", 401, code);
+ assertTrue("Filter should have run before blocking",
+ BlockingFilter.callCount.get() >= 1);
+ }
+
+ /**
+ * Without {@code useCustomAuthFilter=true}, no custom filter is installed
+ * and requests proceed normally; the recording filter sees nothing even
+ * though it is present on the classpath.
+ */
+ @Test(timeout = 30_000)
+ public void testCustomAuthFilterNotInstalledWhenDisabled() throws Exception {
+ int port = freePort();
+ server = new HttpServer.Builder("test")
+ .setConf(new HiveConf())
+ .setHost("localhost")
+ .setPort(port)
+ .build();
+ server.start();
+
+ int code = doGet("/jmx");
+ assertEquals(200, code);
+ assertEquals("Filter must not be installed when useCustomAuthFilter is off",
+ 0, RecordingFilter.callCount.get());
+ }
+
+ // ---- helpers -------------------------------------------------------------
+
+ /**
+ * Picks a currently-free port. HttpServer's PortHandlerWrapper keys handlers
+ * by the configured port, so we cannot pass 0 (dynamic) — the actual bound
+ * port would not match the registered handler. There is a small race window
+ * between closing this socket and Jetty binding, which is acceptable for a
+ * unit test.
+ */
+ private static int freePort() throws IOException {
+ try (ServerSocket s = new ServerSocket(0)) {
+ return s.getLocalPort();
+ }
+ }
+
+ private int doGet(String path) throws IOException {
+ URL url = new URL("http://localhost:" + server.getPort() + path);
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
+ try {
+ conn.setRequestMethod("GET");
+ conn.setConnectTimeout(5_000);
+ conn.setReadTimeout(5_000);
+ return conn.getResponseCode();
+ } finally {
+ conn.disconnect();
+ }
+ }
+
+ // ---- sample filters ------------------------------------------------------
+
+ /** Records every invocation and the init parameters seen at startup. */
+ public static class RecordingFilter implements Filter {
+ static final AtomicInteger callCount = new AtomicInteger(0);
+ static volatile Map initParams = new HashMap<>();
+
+ static void reset() {
+ callCount.set(0);
+ initParams = new HashMap<>();
+ }
+
+ @Override
+ public void init(FilterConfig fc) {
+ Map seen = new HashMap<>();
+ Enumeration names = fc.getInitParameterNames();
+ while (names.hasMoreElements()) {
+ String n = names.nextElement();
+ seen.put(n, fc.getInitParameter(n));
+ }
+ initParams = seen;
+ }
+
+ @Override
+ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
+ throws IOException, ServletException {
+ callCount.incrementAndGet();
+ chain.doFilter(req, resp);
+ }
+
+ @Override
+ public void destroy() {
+ }
+ }
+
+ /** Short-circuits every request with 401, like a deny-by-default auth filter. */
+ public static class BlockingFilter implements Filter {
+ static final AtomicInteger callCount = new AtomicInteger(0);
+
+ static void reset() {
+ callCount.set(0);
+ }
+
+ @Override
+ public void init(FilterConfig fc) {
+ }
+
+ @Override
+ public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
+ throws IOException, ServletException {
+ callCount.incrementAndGet();
+ ((HttpServletResponse) resp).sendError(HttpServletResponse.SC_UNAUTHORIZED, "blocked");
+ }
+
+ @Override
+ public void destroy() {
+ }
+ }
+}
diff --git a/common/src/test/resources/hive-webapps/test/index.html b/common/src/test/resources/hive-webapps/test/index.html
new file mode 100644
index 000000000000..8919831f0e6f
--- /dev/null
+++ b/common/src/test/resources/hive-webapps/test/index.html
@@ -0,0 +1 @@
+test webapp root
diff --git a/service/src/java/org/apache/hive/service/server/HiveServer2.java b/service/src/java/org/apache/hive/service/server/HiveServer2.java
index 5f7f071d765f..f5f64843f86f 100644
--- a/service/src/java/org/apache/hive/service/server/HiveServer2.java
+++ b/service/src/java/org/apache/hive/service/server/HiveServer2.java
@@ -465,7 +465,8 @@ private void addHAContextAttributes(HttpServer.Builder builder, HiveConf hiveCon
builder.setContextAttribute("hs2.failover.callback", new FailoverHandlerCallback(hs2HARegistry));
}
- private static HttpServer.Builder createHttpServerBuilder(String webHost, int port, String name, String contextPath,
+ @VisibleForTesting
+ static HttpServer.Builder createHttpServerBuilder(String webHost, int port, String name, String contextPath,
HiveConf hiveConf, CLIService cliService, PamAuthenticator pamAuthenticator) throws IOException {
HttpServer.Builder builder = new HttpServer.Builder(name);
builder.setConf(hiveConf);
@@ -542,6 +543,20 @@ private static HttpServer.Builder createHttpServerBuilder(String webHost, int po
throw new IllegalArgumentException(ConfVars.HIVE_SERVER2_WEBUI_USE_SSL.varname + " has false value. It is recommended to set to true when PAM is used.");
}
}
+ if (hiveConf.getBoolVar(ConfVars.HIVE_SERVER2_WEBUI_USE_CUSTOM_AUTH_FILTER)) {
+ String authFilter = hiveConf.getVar(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER);
+ if (authFilter == null || authFilter.isEmpty()) {
+ throw new IllegalArgumentException(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname
+ + " is not configured. It is required when Custom Auth Filter is used.");
+ }
+ String paramPrefix = ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname + ".param.";
+ Map params = hiveConf.getPropsWithPrefix(paramPrefix);
+
+ builder.setUseCustomAuthFilter(true);
+ builder.setCustomAuthFilter(authFilter);
+ builder.setCustomAuthFilterParams(params);
+ LOG.info("WebUI will use Custom Auth Filter: {} params: {}", authFilter, params);
+ }
return builder;
}
diff --git a/service/src/test/org/apache/hive/service/server/TestHiveServer2.java b/service/src/test/org/apache/hive/service/server/TestHiveServer2.java
index 42dbdb887957..80492d9479db 100644
--- a/service/src/test/org/apache/hive/service/server/TestHiveServer2.java
+++ b/service/src/test/org/apache/hive/service/server/TestHiveServer2.java
@@ -20,10 +20,22 @@
import org.apache.hadoop.hive.conf.Constants;
import org.apache.hadoop.hive.conf.HiveConf;
+import org.apache.hadoop.hive.conf.HiveConf.ConfVars;
import org.apache.hadoop.hive.metastore.conf.MetastoreConf;
+import org.apache.hive.http.HttpServer;
+import org.apache.hive.service.cli.CLIService;
+import org.apache.hive.service.cli.session.SessionManager;
import org.junit.Test;
+
import java.util.Map;
+
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
public class TestHiveServer2 {
@@ -98,4 +110,72 @@ public void testMaybeStartCompactorThreadsMultipleCustomPoolsAndDefaultPool() {
assertEquals(Integer.valueOf(5), startedWorkers.get("pool3"));
assertEquals(Integer.valueOf(3), startedWorkers.get(Constants.COMPACTION_DEFAULT_POOL));
}
+
+ // ---- WebUI custom auth filter wiring (createHttpServerBuilder) ----------
+
+ /**
+ * Default config: custom auth filter is off, builder must not carry any
+ * filter wiring.
+ */
+ @Test
+ public void testCustomAuthFilterDisabledByDefault() throws Exception {
+ HiveConf conf = new HiveConf();
+ HttpServer.Builder builder = invokeCreateHttpServerBuilder(conf);
+
+ assertFalse("useCustomAuthFilter should default to false",
+ builder.useCustomAuthFilter);
+ }
+
+ /**
+ * When the ConfVars are set, createHttpServerBuilder must thread the filter
+ * class name and every {@code ...custom.auth.filter.param.} key into
+ * the Builder (the param key is stored without the prefix).
+ */
+ @Test
+ public void testCustomAuthFilterWiredFromConfig() throws Exception {
+ HiveConf conf = new HiveConf();
+ conf.setBoolVar(ConfVars.HIVE_SERVER2_WEBUI_USE_CUSTOM_AUTH_FILTER, true);
+ conf.setVar(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER, "com.example.MyAuthFilter");
+ conf.set(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname + ".param.realm", "hive");
+ conf.set(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname + ".param.ttl", "600");
+
+ HttpServer.Builder builder = invokeCreateHttpServerBuilder(conf);
+
+ assertTrue("useCustomAuthFilter should be true",
+ builder.useCustomAuthFilter);
+ assertEquals("com.example.MyAuthFilter",
+ builder.customAuthFilter);
+
+ @SuppressWarnings("unchecked")
+ Map params = builder.customAuthFilterParams;
+ assertNotNull("customAuthFilterParams must be populated", params);
+ assertEquals("hive", params.get("realm"));
+ assertEquals("600", params.get("ttl"));
+ }
+
+ /**
+ * Enabling the filter without providing a class name is a configuration
+ * error and must fail fast at builder construction.
+ */
+ @Test
+ public void testCustomAuthFilterRejectsEmptyClassName() throws Exception {
+ HiveConf conf = new HiveConf();
+ conf.setBoolVar(ConfVars.HIVE_SERVER2_WEBUI_USE_CUSTOM_AUTH_FILTER, true);
+ conf.setVar(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER, "");
+
+ try {
+ invokeCreateHttpServerBuilder(conf);
+ fail("Expected IllegalArgumentException when custom auth filter class name is empty");
+ } catch (IllegalArgumentException expected) {
+ assertTrue("Exception message should reference the custom auth filter ConfVar",
+ expected.getMessage().contains(ConfVars.HIVE_SERVER2_WEBUI_CUSTOM_AUTH_FILTER.varname));
+ }
+ }
+
+ private static HttpServer.Builder invokeCreateHttpServerBuilder(HiveConf conf) throws Exception {
+ CLIService cli = mock(CLIService.class);
+ SessionManager sm = mock(SessionManager.class);
+ when(cli.getSessionManager()).thenReturn(sm);
+ return HiveServer2.createHttpServerBuilder("localhost", 0, "test", "/", conf, cli, null);
+ }
}